diff --git a/api/drf_views.py b/api/drf_views.py index 541566c846..9a7227a407 100644 --- a/api/drf_views.py +++ b/api/drf_views.py @@ -56,6 +56,7 @@ from deployments.models import Personnel from main.enums import GlobalEnumSerializer, get_enum_values from main.filters import NullsLastOrderingFilter +from main.permissions import DenyGuestUserMutationPermission from main.utils import is_tableau from per.models import Overview from per.serializers import CountryLatestOverviewSerializer @@ -814,7 +815,7 @@ def get_serializer_class(self): class ProfileViewset(viewsets.ModelViewSet): serializer_class = ProfileSerializer authentication_classes = (TokenAuthentication,) - permission_classes = (IsAuthenticated,) + permission_classes = (IsAuthenticated, DenyGuestUserMutationPermission) def get_queryset(self): return Profile.objects.filter(user=self.request.user) @@ -823,7 +824,7 @@ def get_queryset(self): class UserViewset(viewsets.ModelViewSet): serializer_class = UserSerializer authentication_classes = (TokenAuthentication,) - permission_classes = (IsAuthenticated,) + permission_classes = (IsAuthenticated, DenyGuestUserMutationPermission) def get_queryset(self): return User.objects.filter(pk=self.request.user.pk) @@ -859,7 +860,7 @@ class FieldReportViewset(ReadOnlyVisibilityViewsetMixin, viewsets.ModelViewSet): ) # for /docs ordering_fields = ("summary", "event", "dtype", "created_at", "updated_at") filterset_class = FieldReportFilter - authentication_class = [IsAuthenticated] + permission_classes = [IsAuthenticated, DenyGuestUserMutationPermission] queryset = FieldReport.objects.select_related("dtype", "event").prefetch_related( "actions_taken", "actions_taken__actions", "countries", "districts", "regions" ) @@ -1274,7 +1275,7 @@ def get(self, _): class ExportViewSet(viewsets.ModelViewSet): serializer_class = ExportSerializer - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, DenyGuestUserMutationPermission] def get_queryset(self): user = self.request.user diff --git a/api/migrations/0211_profile_limit_access_to_guest.py b/api/migrations/0211_profile_limit_access_to_guest.py new file mode 100644 index 0000000000..e9fab1cf59 --- /dev/null +++ b/api/migrations/0211_profile_limit_access_to_guest.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.13 on 2024-06-20 10:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0210_profile_accepted_montandon_license_terms'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='limit_access_to_guest', + field=models.BooleanField(default=True, verbose_name='is not guest user'), + ), + ] diff --git a/api/models.py b/api/models.py index 72157b6ff9..f96e62b93e 100644 --- a/api/models.py +++ b/api/models.py @@ -1836,6 +1836,7 @@ class OrgTypes(models.TextChoices): phone_number = models.CharField(verbose_name=_("phone number"), blank=True, null=True, max_length=100) last_frontend_login = models.DateTimeField(verbose_name=_("last frontend login"), null=True, blank=True) accepted_montandon_license_terms = models.BooleanField(verbose_name=_("has accepted montandon license terms?"), default=False) + limit_access_to_guest = models.BooleanField(verbose_name=_("is not guest user"), default=True) class Meta: verbose_name = _("user profile") diff --git a/api/serializers.py b/api/serializers.py index 371f5370a4..0f3c7ff521 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -1699,6 +1699,7 @@ class UserMeSerializer(UserSerializer): is_per_admin_for_regions = serializers.SerializerMethodField() is_per_admin_for_countries = serializers.SerializerMethodField() user_countries_regions = serializers.SerializerMethodField() + limit_access_to_guest = serializers.BooleanField(source="profile.limit_access_to_guest") class Meta: model = User @@ -1710,6 +1711,7 @@ class Meta: "is_per_admin_for_regions", "is_per_admin_for_countries", "user_countries_regions", + "limit_access_to_guest", ) @staticmethod diff --git a/api/test_views.py b/api/test_views.py index 9d0a2227d7..df6613678c 100644 --- a/api/test_views.py +++ b/api/test_views.py @@ -11,9 +11,83 @@ EventFeaturedDocumentFactory, EventLinkFactory, ) +from api.models import Profile from main.test_case import APITestCase, SnapshotTestCase +class GuestUserPermissionTest(APITestCase): + def setUp(self): + self.guest_user = User.objects.create(username="guest") + + self.go_user = User.objects.create(username="go-user") + + profile = Profile.objects.get(user=self.guest_user) + profile.limit_access_to_guest = False + profile.save() + + def test_guest_user_permission(self): + body = {} + guest_apis = [ + "/api/v2/add_subscription/", + "/api/v2/del_subscription/", + ] + + go_apis = [ + "/api/v2/dref/", + "/api/v2/dref-final-report/", + "/api/v2/dref-final-report/{id}/publish/", + "/api/v2/dref-op-update/", + "/api/v2/dref-op-update/{id}/publish/", + "/api/v2/dref-share/", + "/api/v2/dref/{id}/publish/", + "/api/v2/external-token/", + "/api/v2/flash-update/", + "/api/v2/flash-update-file/multiple/", + "/api/v2/local-units/", + "/api/v2/local-units/{id}/validate/", + "/api/v2/pdf-export/", + "/api/v2/per-assessment/", + "/api/v2/per-document-upload/", + "/api/v2/per-file/multiple/", + "/api/v2/per-prioritization/", + "/api/v2/per-work-plan/", + "/api/v2/project/", + "/api/v2/dref-files/", + "/api/v2/dref-files/multiple/", + "/api/v2/field-report/", + "/api/v2/flash-update-file/", + "/api/v2/per-file/", + "/api/v2/share-flash-update/", + "/api/v2/add_cronjob_log/", + "/api/v2/profile/", + "/api/v2/subscription/", + "/api/v2/user/", + ] + + go_apis_req_additional_perm = [ + "/api/v2/ops-learning/", + "/api/v2/per-overview/", + "/api/v2/user/{id}/accepted_license_terms/", + "/api/v2/language/{id}/bulk-action/", + ] + + self.authenticate(user=self.guest_user) + for api_url in go_apis + go_apis_req_additional_perm: + response = self.client.post(api_url, json=body).json() + self.assertIn(response["error_code"], [401, 403]) + + for api_url in guest_apis: + response = self.client.post(api_url, json=body).json() + error_code = response.get("error_code", None) + self.assertNotIn(error_code, [403, 401]) + + self.authenticate(user=self.go_user) + for api_url in go_apis: + response = self.client.post(api_url, json=body).json() + error_code = response.get("error_code", None) + self.assertNotIn(error_code, [403, 401]) + + class AuthTokenTest(APITestCase): def setUp(self): user = User.objects.create(username="jo") diff --git a/api/views.py b/api/views.py index 6ea078360d..acb1a5808a 100644 --- a/api/views.py +++ b/api/views.py @@ -43,6 +43,7 @@ Statuses, ) from flash_update.models import FlashUpdate +from main.permissions import DenyGuestUserMutationPermission from notifications.models import Subscription, SurgeAlert from notifications.notification import send_notification from registrations.models import Pending, Recovery @@ -973,7 +974,7 @@ def post(self, request): class AddCronJobLog(APIView): authentication_classes = (authentication.TokenAuthentication,) - permissions_classes = (permissions.IsAuthenticated,) + permission_classes = [permissions.IsAuthenticated, DenyGuestUserMutationPermission] def post(self, request): errors, created = CronJob.sync_cron(request.data) diff --git a/deployments/drf_views.py b/deployments/drf_views.py index 124b89b1f1..f187ced2bc 100644 --- a/deployments/drf_views.py +++ b/deployments/drf_views.py @@ -23,6 +23,7 @@ from api.models import Country, Region from api.view_filters import ListFilter from api.visibility_class import ReadOnlyVisibilityViewsetMixin +from main.permissions import DenyGuestUserMutationPermission from main.serializers import CsvListMixin from main.utils import is_tableau @@ -434,7 +435,7 @@ def get_permissions(self): if self.action in ["list", "retrieve"]: permission_classes = [] else: - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, DenyGuestUserMutationPermission] return [permission() for permission in permission_classes] diff --git a/dref/views.py b/dref/views.py index 56bd5888cc..ffb538c929 100644 --- a/dref/views.py +++ b/dref/views.py @@ -35,6 +35,7 @@ DrefShareUserSerializer, MiniDrefSerializer, ) +from main.permissions import DenyGuestUserMutationPermission def filter_dref_queryset_by_user_access(user, queryset): @@ -58,7 +59,7 @@ def filter_dref_queryset_by_user_access(user, queryset): class DrefViewSet(RevisionMixin, viewsets.ModelViewSet): serializer_class = DrefSerializer - permission_classes = [permissions.IsAuthenticated] + permission_classes = [permissions.IsAuthenticated, DenyGuestUserMutationPermission] filterset_class = DrefFilter def get_queryset(self): @@ -75,7 +76,7 @@ def get_queryset(self): url_path="publish", methods=["post"], serializer_class=DrefSerializer, - permission_classes=[permissions.IsAuthenticated, PublishDrefPermission], + permission_classes=[permissions.IsAuthenticated, PublishDrefPermission, DenyGuestUserMutationPermission], ) def get_published(self, request, pk=None, version=None): dref = self.get_object() @@ -88,7 +89,7 @@ def get_published(self, request, pk=None, version=None): class DrefOperationalUpdateViewSet(RevisionMixin, viewsets.ModelViewSet): serializer_class = DrefOperationalUpdateSerializer - permission_classes = [permissions.IsAuthenticated] + permission_classes = [permissions.IsAuthenticated, DenyGuestUserMutationPermission] filterset_class = DrefOperationalUpdateFilter def get_queryset(self): @@ -122,7 +123,7 @@ def get_queryset(self): url_path="publish", methods=["post"], serializer_class=DrefOperationalUpdateSerializer, - permission_classes=[permissions.IsAuthenticated, PublishDrefPermission], + permission_classes=[permissions.IsAuthenticated, PublishDrefPermission, DenyGuestUserMutationPermission], ) def get_published(self, request, pk=None, version=None): operational_update = self.get_object() @@ -135,7 +136,7 @@ def get_published(self, request, pk=None, version=None): class DrefFinalReportViewSet(RevisionMixin, viewsets.ModelViewSet): serializer_class = DrefFinalReportSerializer - permission_classes = [permissions.IsAuthenticated] + permission_classes = [permissions.IsAuthenticated, DenyGuestUserMutationPermission] def get_queryset(self): user = self.request.user @@ -154,7 +155,7 @@ def get_queryset(self): url_path="publish", methods=["post"], serializer_class=DrefFinalReportSerializer, - permission_classes=[permissions.IsAuthenticated, PublishDrefPermission], + permission_classes=[permissions.IsAuthenticated, PublishDrefPermission, DenyGuestUserMutationPermission], ) def get_published(self, request, pk=None, version=None): field_report = self.get_object() @@ -171,7 +172,7 @@ def get_published(self, request, pk=None, version=None): class DrefFileViewSet(mixins.ListModelMixin, mixins.CreateModelMixin, viewsets.GenericViewSet): - permission_class = [permissions.IsAuthenticated] + permission_classes = [permissions.IsAuthenticated, DenyGuestUserMutationPermission] serializer_class = DrefFileSerializer def get_queryset(self): @@ -184,7 +185,7 @@ def get_queryset(self): detail=False, url_path="multiple", methods=["POST"], - permission_classes=[permissions.IsAuthenticated], + permission_classes=[permissions.IsAuthenticated, DenyGuestUserMutationPermission], ) def multiple_file(self, request, pk=None, version=None): # converts querydict to original dict @@ -225,7 +226,7 @@ def get_queryset(self): class DrefShareView(views.APIView): - permission_classes = [permissions.IsAuthenticated] + permission_classes = [permissions.IsAuthenticated, DenyGuestUserMutationPermission] @extend_schema(request=AddDrefUserSerializer, responses=None) def post(self, request): diff --git a/flash_update/views.py b/flash_update/views.py index cfad401fa2..ebfe292074 100644 --- a/flash_update/views.py +++ b/flash_update/views.py @@ -14,6 +14,7 @@ from rest_framework.response import Response from api.serializers import ActionSerializer +from main.permissions import DenyGuestUserMutationPermission from .filter_set import FlashUpdateFilter from .models import ( @@ -38,7 +39,7 @@ class FlashUpdateViewSet(viewsets.ModelViewSet): serializer_class = FlashUpdateSerializer - permission_classes = [permissions.IsAuthenticated] + permission_classes = [permissions.IsAuthenticated, DenyGuestUserMutationPermission] filterset_class = FlashUpdateFilter def get_queryset(self): @@ -68,7 +69,7 @@ def get_queryset(self): class FlashUpdateFileViewSet(mixins.ListModelMixin, mixins.CreateModelMixin, viewsets.GenericViewSet): - permission_class = [permissions.IsAuthenticated] + permission_classes = [permissions.IsAuthenticated, DenyGuestUserMutationPermission] serializer_class = FlashGraphicMapSerializer def get_queryset(self): @@ -79,7 +80,7 @@ def get_queryset(self): detail=False, url_path="multiple", methods=["POST"], - permission_classes=[permissions.IsAuthenticated], + permission_classes=[permissions.IsAuthenticated, DenyGuestUserMutationPermission], ) def multiple_file(self, request, pk=None, version=None): files = [files[0] for files in dict((request.data).lists()).values()] @@ -112,7 +113,7 @@ class DonorsViewSet(viewsets.ReadOnlyModelViewSet): class ShareFlashUpdateViewSet(mixins.CreateModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): queryset = FlashUpdateShare.objects.all() serializer_class = ShareFlashUpdateSerializer - permission_class = [permissions.IsAuthenticated] + permission_classes = [permissions.IsAuthenticated, DenyGuestUserMutationPermission] class ExportFlashUpdateView(views.APIView): diff --git a/lang/views.py b/lang/views.py index 03e26f8fd9..e34e7c8015 100644 --- a/lang/views.py +++ b/lang/views.py @@ -9,6 +9,8 @@ from rest_framework.authentication import TokenAuthentication from rest_framework.decorators import action as djaction +from main.permissions import DenyGuestUserMutationPermission + from .models import String from .permissions import LangStringPermission from .serializers import ( @@ -24,7 +26,7 @@ class LanguageViewSet(viewsets.ViewSet): # TODO: Cache retrive response to file authentication_classes = (TokenAuthentication,) - permission_classes = (LangStringPermission,) + permission_classes = (LangStringPermission, DenyGuestUserMutationPermission) lookup_url_kwarg = "pk" @extend_schema(request=None, responses=LanguageListSerializer) diff --git a/local_units/views.py b/local_units/views.py index 7affa78a43..3a6dc08051 100644 --- a/local_units/views.py +++ b/local_units/views.py @@ -33,6 +33,7 @@ PrivateLocalUnitDetailSerializer, PrivateLocalUnitSerializer, ) +from main.permissions import DenyGuestUserMutationPermission class PrivateLocalUnitViewSet(viewsets.ModelViewSet): @@ -47,7 +48,7 @@ class PrivateLocalUnitViewSet(viewsets.ModelViewSet): "local_branch_name", "english_branch_name", ) - permission_classes = [permissions.IsAuthenticated, IsAuthenticatedForLocalUnit] + permission_classes = [permissions.IsAuthenticated, IsAuthenticatedForLocalUnit, DenyGuestUserMutationPermission] def get_serializer_class(self): if self.action == "list": @@ -63,7 +64,7 @@ def destroy(self, request, *args, **kwargs): url_path="validate", methods=["post"], serializer_class=PrivateLocalUnitSerializer, - permission_classes=[permissions.IsAuthenticated, ValidateLocalUnitPermission], + permission_classes=[permissions.IsAuthenticated, ValidateLocalUnitPermission, DenyGuestUserMutationPermission], ) def get_validate(self, request, pk=None, version=None): local_unit = self.get_object() diff --git a/main/permissions.py b/main/permissions.py index f0ae7c110c..03d3384728 100644 --- a/main/permissions.py +++ b/main/permissions.py @@ -1,5 +1,7 @@ from rest_framework import permissions +from api.models import Profile + class ModifyBySuperAdminOnly(permissions.BasePermission): def has_permission(self, request, view): @@ -10,3 +12,31 @@ def has_permission(self, request, view): def has_object_permission(self, request, view, obj): return self.has_permission(request, view) + + +class DenyGuestUserMutationPermission(permissions.BasePermission): + """ + Custom permission to deny mutation actions for logged-in guest users. + + This permission class allows all safe (read-only) operations but restricts + any mutation (write, update, delete) operations if the user is a guest. + """ + + def _has_permission(self, request, view): + # Allow all safe methods (GET, HEAD, OPTIONS) which are non-mutating. + if request.method in permissions.SAFE_METHODS: + return True + + # For mutation methods (POST, PUT, DELETE, etc.): + # Check if the user is authenticated. + if not bool(request.user and request.user.is_authenticated): + # Deny access if the user is not authenticated. + return False + + return Profile.objects.filter(user=request.user, limit_access_to_guest=True).exists() + + def has_permission(self, request, view): + return self._has_permission(request, view) + + def has_object_permission(self, request, view, obj): + return self._has_permission(request, view) diff --git a/notifications/drf_views.py b/notifications/drf_views.py index 2447338cbf..6624680684 100644 --- a/notifications/drf_views.py +++ b/notifications/drf_views.py @@ -8,6 +8,7 @@ from deployments.models import MolnixTag from main.filters import CharInFilter +from main.permissions import DenyGuestUserMutationPermission from .models import Subscription, SurgeAlert from .serializers import ( # UnauthenticatedSurgeAlertSerializer, @@ -87,7 +88,7 @@ def get_serializer_class(self): class SubscriptionViewset(viewsets.ModelViewSet): serializer_class = SubscriptionSerializer authentication_classes = (TokenAuthentication,) - permission_classes = (IsAuthenticated,) + permission_classes = (IsAuthenticated, DenyGuestUserMutationPermission) search_fields = ("user__username", "rtype") # for /docs def get_queryset(self): diff --git a/per/drf_views.py b/per/drf_views.py index b736e9eba3..b7ec653a62 100644 --- a/per/drf_views.py +++ b/per/drf_views.py @@ -19,6 +19,7 @@ from api.models import Country from deployments.models import SectorTag +from main.permissions import DenyGuestUserMutationPermission from main.utils import SpreadSheetContentNegotiation from per.filter_set import ( PerDocumentFilter, @@ -234,7 +235,7 @@ def get_queryset(self): class PerOverviewViewSet(viewsets.ModelViewSet): serializer_class = PerOverviewSerializer - permission_classes = [IsAuthenticated, PerPermission] + permission_classes = [IsAuthenticated, PerPermission, DenyGuestUserMutationPermission] filterset_class = PerOverviewFilter ordering_fields = "__all__" get_request_user_regions = RegionRestrictedAdmin.get_request_user_regions @@ -506,7 +507,7 @@ def get(self, request, pk, format=None): class NewPerWorkPlanViewSet(viewsets.ModelViewSet): - permission_classes = (IsAuthenticated, PerGeneralPermission) + permission_classes = (IsAuthenticated, PerGeneralPermission, DenyGuestUserMutationPermission) queryset = PerWorkPlan.objects.all() serializer_class = PerWorkPlanSerializer filterset_class = PerWorkPlanFilter @@ -523,7 +524,7 @@ class FormPrioritizationViewSet(viewsets.ModelViewSet): serializer_class = FormPrioritizationSerializer queryset = FormPrioritization.objects.all() filterset_class = PerPrioritizationFilter - permission_classes = (IsAuthenticated, PerGeneralPermission) + permission_classes = (IsAuthenticated, PerGeneralPermission, DenyGuestUserMutationPermission) ordering_fields = "__all__" @@ -574,7 +575,7 @@ def get_queryset(self): class FormAssessmentViewSet(viewsets.ModelViewSet): serializer_class = PerAssessmentSerializer - permission_classes = [permissions.IsAuthenticated, PerGeneralPermission] + permission_classes = [permissions.IsAuthenticated, PerGeneralPermission, DenyGuestUserMutationPermission] ordering_fields = "__all__" def get_queryset(self): @@ -590,7 +591,7 @@ def get_queryset(self): class PerFileViewSet(mixins.ListModelMixin, mixins.CreateModelMixin, viewsets.GenericViewSet): - permission_class = [permissions.IsAuthenticated] + permission_classes = [permissions.IsAuthenticated, DenyGuestUserMutationPermission] serializer_class = PerFileSerializer def get_queryset(self): @@ -603,7 +604,7 @@ def get_queryset(self): detail=False, url_path="multiple", methods=["POST"], - permission_classes=[permissions.IsAuthenticated], + permission_classes=[permissions.IsAuthenticated, DenyGuestUserMutationPermission], ) def multiple_file(self, request, pk=None, version=None): # converts querydict to original dict @@ -707,7 +708,7 @@ class OpsLearningViewset(viewsets.ModelViewSet): """ queryset = OpsLearning.objects.all() - permission_classes = [OpsLearningPermission] + permission_classes = [DenyGuestUserMutationPermission, OpsLearningPermission] filterset_class = OpsLearningFilter search_fields = ( "learning", @@ -809,7 +810,7 @@ class PerDocumentUploadViewSet(viewsets.ModelViewSet): queryset = PerDocumentUpload.objects.all() serializer_class = PerDocumentUploadSerializer filterset_class = PerDocumentFilter - permission_classes = [permissions.IsAuthenticated, PerDocumentUploadPermission] + permission_classes = [permissions.IsAuthenticated, PerDocumentUploadPermission, DenyGuestUserMutationPermission] def get_queryset(self): queryset = super().get_queryset() diff --git a/registrations/views.py b/registrations/views.py index 751aff0e0c..5e5be4d61d 100644 --- a/registrations/views.py +++ b/registrations/views.py @@ -10,6 +10,7 @@ from rest_framework.views import APIView from api.views import bad_http_request, bad_request +from main.permissions import DenyGuestUserMutationPermission from notifications.notification import send_notification from registrations.serializers import UserExternalTokenSerializer @@ -147,7 +148,7 @@ def get(self, request): class UserExternalTokenViewset(viewsets.ModelViewSet): serializer_class = UserExternalTokenSerializer - permission_classes = [permissions.IsAuthenticated] + permission_classes = [permissions.IsAuthenticated, DenyGuestUserMutationPermission] def get_queryset(self): return UserExternalToken.objects.filter(user=self.request.user)