diff --git a/src/backend/core/api/client/viewsets.py b/src/backend/core/api/client/viewsets.py index 50167bf0e..95a1ef106 100644 --- a/src/backend/core/api/client/viewsets.py +++ b/src/backend/core/api/client/viewsets.py @@ -1,7 +1,11 @@ """API endpoints""" +import operator +from functools import reduce + from django.conf import settings -from django.db.models import OuterRef, Q, Subquery +from django.db.models import OuterRef, Q, Subquery, Value +from django.db.models.functions import Coalesce from rest_framework import ( decorators, @@ -299,13 +303,21 @@ class TeamViewSet( permission_classes = [permissions.AccessPermission] serializer_class = serializers.TeamSerializer filter_backends = [filters.OrderingFilter] - ordering_fields = ["created_at"] + ordering_fields = ["created_at", "name", "path"] ordering = ["-created_at"] queryset = models.Team.objects.all() pagination_class = None def get_queryset(self): """Custom queryset to get user related teams.""" + teams_queryset = models.Team.objects.filter( + accesses__user=self.request.user, + ) + depth_path = teams_queryset.values("depth", "path") + + if not depth_path: + return models.Team.objects.none() + user_role_query = models.TeamAccess.objects.filter( user=self.request.user, team=OuterRef("pk") ).values("role")[:1] @@ -313,9 +325,32 @@ def get_queryset(self): return ( models.Team.objects.prefetch_related("accesses", "service_providers") .filter( - accesses__user=self.request.user, + reduce( + operator.or_, + ( + Q( + # The team the user has access to + depth=d["depth"], + path=d["path"], + ) + | Q( + # The parent team the user has access to + depth__lt=d["depth"], + path__startswith=d["path"][: models.Team.steplen], + organization_id=self.request.user.organization_id, + ) + for d in depth_path + ), + ), + ) + # Abilities are computed based on logged-in user's role for the team + # and if the user does not have access, it's ok to consider them as a member + # because it's a parent team. + .annotate( + user_role=Coalesce( + Subquery(user_role_query), Value(models.RoleChoices.MEMBER.value) + ) ) - .annotate(user_role=Subquery(user_role_query)) ) def perform_create(self, serializer): diff --git a/src/backend/core/tests/teams/test_core_api_teams_delete.py b/src/backend/core/tests/teams/test_core_api_teams_delete.py index 4e3de4fa3..da3a9a77f 100644 --- a/src/backend/core/tests/teams/test_core_api_teams_delete.py +++ b/src/backend/core/tests/teams/test_core_api_teams_delete.py @@ -12,6 +12,7 @@ from rest_framework.test import APIClient from core import factories, models +from core.models import Team pytestmark = pytest.mark.django_db @@ -113,3 +114,59 @@ def test_api_teams_delete_authenticated_owner(): assert response.status_code == HTTP_204_NO_CONTENT assert models.Team.objects.exists() is False + + +@pytest.mark.parametrize( + "role", + ["owner", "administrator", "member"], +) +def test_api_teams_delete_authenticated_owner_parent_team(client, role): + """ + Authenticated users should not be able to delete a parent team they + don't own. + """ + user = factories.UserFactory() + + client.force_login(user) + + root_team = Team.objects.create(name="Root") + first_team = Team.objects.create(name="First", parent_id=root_team.pk) + second_team = Team.objects.create(name="Second", parent_id=first_team.pk) + + # user is a member of the second team + factories.TeamAccessFactory(team=second_team, user=user, role=role) + + response = client.delete(f"/api/v1.0/teams/{first_team.pk}/") + + assert response.status_code == HTTP_403_FORBIDDEN + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + assert models.Team.objects.count() == 3 + + +@pytest.mark.parametrize( + "role", + ["owner", "administrator", "member"], +) +def test_api_teams_delete_authenticated_owner_child_team(client, role): + """ + Authenticated users should not be able to delete a children team they + don't own. + """ + user = factories.UserFactory() + + client.force_login(user) + + root_team = Team.objects.create(name="Root") + first_team = Team.objects.create(name="First", parent_id=root_team.pk) + second_team = Team.objects.create(name="Second", parent_id=first_team.pk) + + # user is a member of the first team + factories.TeamAccessFactory(team=first_team, user=user, role=role) + + response = client.delete(f"/api/v1.0/teams/{second_team.pk}/") + + assert response.status_code == HTTP_404_NOT_FOUND + assert response.json() == {"detail": "No Team matches the given query."} + assert models.Team.objects.count() == 3 diff --git a/src/backend/core/tests/teams/test_core_api_teams_list.py b/src/backend/core/tests/teams/test_core_api_teams_list.py index 24ef55a56..f01361af7 100644 --- a/src/backend/core/tests/teams/test_core_api_teams_list.py +++ b/src/backend/core/tests/teams/test_core_api_teams_list.py @@ -7,6 +7,7 @@ from rest_framework.test import APIClient from core import factories +from core.models import Team pytestmark = pytest.mark.django_db @@ -125,3 +126,189 @@ def test_api_teams_order_param(): assert ( response_team_ids == team_ids ), "created_at values are not sorted from oldest to newest" + + +@pytest.mark.parametrize( + "role,local_team_abilities", + [ + ( + "owner", + { + "delete": True, + "get": True, + "manage_accesses": True, + "patch": True, + "put": True, + }, + ), + ( + "administrator", + { + "delete": False, + "get": True, + "manage_accesses": True, + "patch": True, + "put": True, + }, + ), + ( + "member", + { + "delete": False, + "get": True, + "manage_accesses": False, + "patch": False, + "put": False, + }, + ), + ], +) +def test_api_teams_list_authenticated_team_tree(client, role, local_team_abilities): + """ + Authenticated users should be able to list teams + they are an owner/administrator/member of, or any parent teams. + """ + user = factories.UserFactory() + + client.force_login(user) + + root_team = Team.objects.create(name="Root") + first_team = Team.objects.create(name="First", parent_id=root_team.pk) + second_team = Team.objects.create(name="Second", parent_id=first_team.pk) + third_team = Team.objects.create(name="Third", parent_id=second_team.pk) + _fourth_team = Team.objects.create(name="Fourth", parent_id=third_team.pk) + + # user is a member of the second team + user_access = factories.TeamAccessFactory(team=second_team, user=user, role=role) + + response = client.get("/api/v1.0/teams/") + + assert response.status_code == HTTP_200_OK + # By default, the teams are sorted by 'created_at' descending + assert response.json() == [ + { + # I have the abilities only on the team I have a specific role + "abilities": local_team_abilities, + "accesses": [str(user_access.pk)], + "created_at": second_team.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + "id": str(second_team.pk), + "name": "Second", + "service_providers": [], + "updated_at": second_team.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + }, + { + # For parent teams, I only have the ability to list/retrieve + "abilities": { + "delete": False, + "get": True, + "manage_accesses": False, + "patch": False, + "put": False, + }, + "accesses": [], + "created_at": first_team.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + "id": str(first_team.pk), + "name": "First", + "service_providers": [], + "updated_at": first_team.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + }, + { + # For parent teams, I only have the ability to list/retrieve + "abilities": { + "delete": False, + "get": True, + "manage_accesses": False, + "patch": False, + "put": False, + }, + "accesses": [], + "created_at": root_team.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + "id": str(root_team.pk), + "name": "Root", + "service_providers": [], + "updated_at": root_team.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + }, + ] + + +@pytest.mark.parametrize( + "role,local_team_abilities", + [ + ( + "owner", + { + "delete": True, + "get": True, + "manage_accesses": True, + "patch": True, + "put": True, + }, + ), + ( + "administrator", + { + "delete": False, + "get": True, + "manage_accesses": True, + "patch": True, + "put": True, + }, + ), + ( + "member", + { + "delete": False, + "get": True, + "manage_accesses": False, + "patch": False, + "put": False, + }, + ), + ], +) +def test_api_teams_list_authenticated_team_different_organization( + client, role, local_team_abilities +): + """ + Authenticated users should be able to list teams they + are an owner/administrator/member of and any parent teams + only if from the same organization. + """ + organization = factories.OrganizationFactory(with_registration_id=True) + user = factories.UserFactory(organization=organization) + + other_organization = factories.OrganizationFactory(with_registration_id=True) + root_team = Team.objects.create(name="Root", organization=other_organization) + first_team = Team.objects.create( + name="First", parent_id=root_team.pk, organization=other_organization + ) + second_team = Team.objects.create( + name="Second", parent_id=first_team.pk, organization=other_organization + ) + third_team = Team.objects.create( + name="Third", parent_id=second_team.pk, organization=other_organization + ) + _fourth_team = Team.objects.create( + name="Fourth", parent_id=third_team.pk, organization=other_organization + ) + + client.force_login(user) + + # user is a member of the second team + user_access = factories.TeamAccessFactory(team=second_team, user=user, role=role) + + response = client.get("/api/v1.0/teams/") + + assert response.status_code == HTTP_200_OK + assert response.json() == [ + { + # I have the abilities only on the team I have a specific role + "abilities": local_team_abilities, + "accesses": [str(user_access.pk)], + "created_at": second_team.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + "id": str(second_team.pk), + "name": "Second", + "service_providers": [], + "updated_at": second_team.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + }, + ] diff --git a/src/backend/core/tests/teams/test_core_api_teams_retrieve.py b/src/backend/core/tests/teams/test_core_api_teams_retrieve.py index 607dec778..a90bf1c10 100644 --- a/src/backend/core/tests/teams/test_core_api_teams_retrieve.py +++ b/src/backend/core/tests/teams/test_core_api_teams_retrieve.py @@ -7,6 +7,7 @@ from rest_framework.test import APIClient from core import factories +from core.models import Team pytestmark = pytest.mark.django_db @@ -74,3 +75,67 @@ def test_api_teams_retrieve_authenticated_related(): "updated_at": team.updated_at.isoformat().replace("+00:00", "Z"), "service_providers": [], } + + +@pytest.mark.parametrize( + "role", + ["owner", "administrator", "member"], +) +def test_api_teams_retrieve_authenticated_related_parent(client, role): + """ + Authenticated users should be allowed to retrieve a parent team + to which they are related through the child team whatever the role. + """ + user = factories.UserFactory() + + client.force_login(user) + + root_team = Team.objects.create(name="Root") + first_team = Team.objects.create(name="First", parent_id=root_team.pk) + second_team = Team.objects.create(name="Second", parent_id=first_team.pk) + + # user is a member of the second team + factories.TeamAccessFactory(team=second_team, user=user, role=role) + + response = client.get(f"/api/v1.0/teams/{first_team.pk!s}/") + + # the abilities enforces the "get" via the queryset + abilities = first_team.get_abilities(user) + abilities["get"] = True + + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + "id": str(first_team.pk), + "name": first_team.name, + "abilities": abilities, + "accesses": [], + "created_at": first_team.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": first_team.updated_at.isoformat().replace("+00:00", "Z"), + "service_providers": [], + } + + +@pytest.mark.parametrize( + "role", + ["owner", "administrator", "member"], +) +def test_api_teams_retrieve_authenticated_related_children(client, role): + """ + Authenticated users should NOT be allowed to retrieve a child team + to which they are related through the parent team whatever the role. + """ + user = factories.UserFactory() + + client.force_login(user) + + root_team = Team.objects.create(name="Root") + first_team = Team.objects.create(name="First", parent_id=root_team.pk) + second_team = Team.objects.create(name="Second", parent_id=first_team.pk) + + # user is a member of the first team + factories.TeamAccessFactory(team=first_team, user=user, role=role) + + response = client.get(f"/api/v1.0/teams/{second_team.pk!s}/") + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.json() == {"detail": "No Team matches the given query."} diff --git a/src/backend/core/tests/teams/test_core_api_teams_update.py b/src/backend/core/tests/teams/test_core_api_teams_update.py index 81379e31c..c5d5cbec2 100644 --- a/src/backend/core/tests/teams/test_core_api_teams_update.py +++ b/src/backend/core/tests/teams/test_core_api_teams_update.py @@ -15,6 +15,7 @@ from core import factories from core.api.client import serializers +from core.models import Team pytestmark = pytest.mark.django_db @@ -219,3 +220,75 @@ def test_api_teams_update_authenticated_owners_add_service_providers(): team.refresh_from_db() assert team.service_providers.count() == 2 assert set(team.service_providers.all()) == {service_provider_1, service_provider_2} + + +@pytest.mark.parametrize( + "role", + ["owner", "administrator", "member"], +) +def test_api_teams_update_whatever_access_of_child_team(client, role): + """ + Being member, administrator or owner of a team should not grant + authorization to update a parent team. + """ + user = factories.UserFactory() + + client.force_login(user) + + root_team = Team.objects.create(name="Root") + first_team = Team.objects.create(name="First", parent_id=root_team.pk) + second_team = Team.objects.create(name="Second", parent_id=first_team.pk) + + # user is a member of the second team + factories.TeamAccessFactory(team=second_team, user=user, role=role) + + response = client.patch( + f"/api/v1.0/teams/{first_team.pk}/", + { + "name": "New name", + }, + format="json", + ) + + assert response.status_code == HTTP_403_FORBIDDEN + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + first_team.refresh_from_db() + assert first_team.name == "First" + + +@pytest.mark.parametrize( + "role", + ["owner", "administrator", "member"], +) +def test_api_teams_update_whatever_access_of_parent_team(client, role): + """ + Being member, administrator or owner of a team should not grant + authorization to update a child team. + """ + user = factories.UserFactory() + + client.force_login(user) + + root_team = Team.objects.create(name="Root") + first_team = Team.objects.create(name="First", parent_id=root_team.pk) + second_team = Team.objects.create(name="Second", parent_id=first_team.pk) + + # user is a member of the first team + factories.TeamAccessFactory(team=first_team, user=user, role=role) + + response = client.patch( + f"/api/v1.0/teams/{second_team.pk}/", + { + "name": "New name", + }, + format="json", + ) + + assert response.status_code == HTTP_404_NOT_FOUND + assert response.json() == {"detail": "No Team matches the given query."} + + second_team.refresh_from_db() + assert second_team.name == "Second" diff --git a/src/backend/core/tests/test_models_teams.py b/src/backend/core/tests/test_models_teams.py index b15771164..9156f01a3 100644 --- a/src/backend/core/tests/test_models_teams.py +++ b/src/backend/core/tests/test_models_teams.py @@ -138,6 +138,7 @@ def test_models_teams_get_abilities_preset_role(django_assert_num_queries): # test trees + def test_models_teams_create_root_team(): """Create a root team.""" team = models.Team.add_root(name="Root Team") @@ -197,4 +198,4 @@ def test_models_teams_manager_create(): child_team = models.Team.objects.create(name="Child Team", parent_id=team.pk) assert child_team.is_child_of(team) - assert child_team.name == "Child Team" \ No newline at end of file + assert child_team.name == "Child Team"