diff --git a/api/drf_views.py b/api/drf_views.py index 541566c84..9a7227a40 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 000000000..0f4619174 --- /dev/null +++ b/api/migrations/0211_profile_limit_access_to_guest.py @@ -0,0 +1,31 @@ +# Generated by Django 4.2.13 on 2024-07-01 09:17 + +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=False, + help_text="User having this value as true explictly is treated as guest user.Despite of having all the permission, if this value is set to true, the user is deprived of all the non-guest user permission.", + verbose_name="limit access to guest user permissions", + ), + ), + migrations.AlterField( + model_name="profile", + name="limit_access_to_guest", + field=models.BooleanField( + default=True, + help_text="User having this value as true explictly is treated as guest user.Despite of having all the permission, if this value is set to true, the user is deprived of all the non-guest user permission.", + verbose_name="limit access to guest user permissions", + ), + ), + ] diff --git a/api/models.py b/api/models.py index 72157b6ff..2dc5bdaf6 100644 --- a/api/models.py +++ b/api/models.py @@ -1836,6 +1836,12 @@ 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( + help_text="User having this value as true explictly is treated as guest user.Despite of having all the \ + permission, if this value is set to true, the user is deprived of all the non-guest user permission.", + verbose_name=_("limit access to guest user permissions"), + default=True, + ) class Meta: verbose_name = _("user profile") diff --git a/api/serializers.py b/api/serializers.py index 371f5370a..0f3c7ff52 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 9d0a2227d..1ccf03cea 100644 --- a/api/test_views.py +++ b/api/test_views.py @@ -11,9 +11,87 @@ EventFeaturedDocumentFactory, EventLinkFactory, ) +from api.models import Profile from main.test_case import APITestCase, SnapshotTestCase +class GuestUserPermissionTest(APITestCase): + def setUp(self): + # Create guest user + self.guest_user = User.objects.create(username="guest") + guest_profile = Profile.objects.get(user=self.guest_user) + guest_profile.limit_access_to_guest = True + guest_profile.save() + + # Create go user + self.go_user = User.objects.create(username="go-user") + go_user_profile = Profile.objects.get(user=self.go_user) + go_user_profile.limit_access_to_guest = False + go_user_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 6ea078360..acb1a5808 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 124b89b1f..f187ced2b 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 56bd5888c..ffb538c92 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 cfad401fa..ebfe29207 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 03e26f8fd..e34e7c801 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 7affa78a4..3a6dc0805 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 f0ae7c110..107b6f268 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=False).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 2447338cb..662468068 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 b736e9eba..b7ec653a6 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 751aff0e0..5e5be4d61 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)