Repository: ephios-dev/ephios

Issue:
Participation API Permissions Might be Wrong
**Describe the bug**
I expect that when a user requests `api/users/[id]/participations` they get a list of the participations that they can see of that user. Alternatively, I would expect the route only to be accessible to some higher level of privilege (e.g., admins) and then users receive a permission error (as opposed to an empty list).

In any case, I would expect an admin to get the full list.

In my testing, I've created a new user and given him all groups and permissions I could think of, see this image:

![screenshot_2024-03-31_at_17 36 58_720](https://github.com/ephios-dev/ephios/assets/2546622/98e484eb-ffd3-49f2-b9f1-9352ac7bb170)

When this user requests `/api/users/3/participations`, they receive the following response

```
HTTP 200 OK
Allow: GET, HEAD, OPTIONS
Content-Type: application/json
Vary: Accept

{
    "count": 0,
    "next": null,
    "previous": null,
    "results": []
}
```

When I access the same route with the `admin@localhost` I get the following (expected) response

```
HTTP 200 OK
Allow: GET, HEAD, OPTIONS
Content-Type: application/json
Vary: Accept

{
    "count": 1,
    "next": null,
    "previous": null,
    "results": [
        {
            "id": 6,
            "shift": 1,
            "event_title": "Concert Medical Service",
            "state": {
                "value": 1,
                "label": "bestätigt"
            },
            "comment": "",
            "start_time": "2043-06-30T14:00:00Z",
            "end_time": "2043-06-30T23:00:00Z",
            "duration": 32400.0
        }
    ]
}
```

Similarly, the new user is not allowed to access the user route (e.g., `http://localhost:8001/api/users/3/`), where they receive the following even if the user exists (and returns as expected for `admin@localhost`).

```
HTTP 404 Not Found
Allow: GET, HEAD, OPTIONS
Content-Type: application/json
Vary: Accept

{
    "detail": "Nicht gefunden."
}
```

**To Reproduce**
Steps to reproduce the behavior:
1. Create a new user.
2. Give them admin permissions.
3. Access `/api/users/[id]/participations` for some existing user who has at least one participation.

**Expected behavior**
As explained above, ideally I would expect a user to see all participations in the API they would be able to see via the web interface. Alternatively, I would expect admins to see the participations of a user. In any case, I would expect permission errors (e.g., a `403`) if a user has insufficient permissions instead of an empty list.

**Environment**
State which device, operating system, browser and browser version you are using.

Reproduced both in Safari 17.2.1 and Firefox 123.0.1 on MacOS 14.2.1. Commit `5025baa91aae7fcbdf754319fc061d51a6093e9f`



Discussion:

Resolving patch:
diff --git a/ephios/api/filters.py b/ephios/api/filters.py
new file mode 100644
--- /dev/null
+++ b/ephios/api/filters.py
@@ -0,0 +1,29 @@
+from django_filters import DateTimeFilter, ModelChoiceFilter
+from django_filters.rest_framework import FilterSet
+from guardian.shortcuts import get_objects_for_user
+from rest_framework.filters import BaseFilterBackend
+
+from ephios.core.models import EventType, LocalParticipation
+
+
+class ParticipationPermissionFilter(BaseFilterBackend):
+    def filter_queryset(self, request, queryset, view):
+        events = get_objects_for_user(request.user, "core.view_event")
+        return queryset.filter(shift__event__in=events)
+
+
+class ParticipationFilterSet(FilterSet):
+    # we cannot use gettext_lazy as it breaks sphinxcontrib.openapi (https://github.com/sphinx-contrib/openapi/issues/153)
+    event_type = ModelChoiceFilter(
+        field_name="shift__event__type", label="event type", queryset=EventType.objects.all()
+    )
+    start_gte = DateTimeFilter(field_name="start_time", lookup_expr="gte", label="start time after")
+    start_lte = DateTimeFilter(
+        field_name="start_time", lookup_expr="lte", label="start time before"
+    )
+    end_gte = DateTimeFilter(field_name="end_time", lookup_expr="gte", label="end time after")
+    end_lte = DateTimeFilter(field_name="end_time", lookup_expr="lte", label="end time before")
+
+    class Meta:
+        model = LocalParticipation
+        fields = ["state"]
diff --git a/ephios/api/views/users.py b/ephios/api/views/users.py
--- a/ephios/api/views/users.py
+++ b/ephios/api/views/users.py
@@ -15,6 +15,7 @@
 from rest_framework.viewsets import GenericViewSet
 from rest_framework_guardian.filters import ObjectPermissionsFilter
 
+from ephios.api.filters import ParticipationFilterSet, ParticipationPermissionFilter
 from ephios.api.views.events import ParticipationSerializer
 from ephios.core.models import LocalParticipation, Qualification, UserProfile
 from ephios.core.services.qualification import collect_all_included_qualifications
@@ -103,8 +104,8 @@ class UserByMailView(RetrieveModelMixin, GenericViewSet):
 class UserParticipationView(viewsets.ReadOnlyModelViewSet):
     serializer_class = ParticipationSerializer
     permission_classes = [IsAuthenticatedOrTokenHasScope]
-    filter_backends = [ObjectPermissionsFilter, DjangoFilterBackend]
-    filterset_fields = ["state"]
+    filter_backends = [ParticipationPermissionFilter, DjangoFilterBackend]
+    filterset_class = ParticipationFilterSet
     required_scopes = ["CONFIDENTIAL_READ"]
 
     def get_queryset(self):


Test patch:
diff --git a/tests/api/test_participation_list.py b/tests/api/test_participation_list.py
new file mode 100644
--- /dev/null
+++ b/tests/api/test_participation_list.py
@@ -0,0 +1,55 @@
+from django.urls import reverse
+from guardian.shortcuts import remove_perm
+
+from ephios.core.models import AbstractParticipation, LocalParticipation
+
+
+def test_participation_list_permissions(django_app, event, planner, groups, volunteer, manager):
+    LocalParticipation.objects.create(
+        user=volunteer, shift=event.shifts.first(), state=AbstractParticipation.States.CONFIRMED
+    )
+    response = django_app.get(
+        reverse("api:user-participations-list", kwargs=dict(user=volunteer.pk)),
+        user=planner,
+        status=200,
+    )
+    assert event.title in response
+
+    # make event invisible to volunteers
+    _, planners, volunteers = groups
+    remove_perm("view_event", planners, event)
+    remove_perm("view_event", volunteers, event)
+    remove_perm("view_event", planner, event)
+
+    response = django_app.get(
+        reverse("api:user-participations-list", kwargs=dict(user=volunteer.pk)),
+        user=planner,
+        status=200,
+    )
+    assert event.title not in response
+    assert event.title in django_app.get(
+        reverse("api:user-participations-list", kwargs=dict(user=volunteer.pk)),
+        user=manager,
+        status=200,
+    )
+
+
+def test_participation_list_filter(
+    django_app, event, planner, groups, volunteer, manager, training_event_type
+):
+    LocalParticipation.objects.create(
+        user=volunteer, shift=event.shifts.first(), state=AbstractParticipation.States.CONFIRMED
+    )
+    response = django_app.get(
+        reverse("api:user-participations-list", kwargs=dict(user=volunteer.pk)),
+        user=planner,
+        status=200,
+    )
+    assert event.title in response
+
+    response = django_app.get(
+        f"{reverse('api:user-participations-list', kwargs=dict(user=volunteer.pk))}?event_type={training_event_type.pk}",
+        user=planner,
+        status=200,
+    )
+    assert event.title not in response