From 288562cc0ee0df32738db11b15a0c526a02dac12 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Tue, 24 Mar 2026 18:59:29 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=82(backend)=20allow=20participants=20?= =?UTF-8?q?to=20mute=20others=20based=20on=20room=20configuration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable any participant to mute others when the room configuration allows it. This is enabled by default for all meetings unless explicitly disabled by an administrator. Privileged users retain the ability to mute any participant regardless of the room configuration. --- src/backend/core/api/permissions.py | 30 ++ src/backend/core/api/viewsets.py | 7 +- .../test_api_rooms_participants_management.py | 295 ++++++++++++++++-- 3 files changed, 311 insertions(+), 21 deletions(-) diff --git a/src/backend/core/api/permissions.py b/src/backend/core/api/permissions.py index c607d80f..8289f1ec 100644 --- a/src/backend/core/api/permissions.py +++ b/src/backend/core/api/permissions.py @@ -136,3 +136,33 @@ class FilePermission(IsAuthenticated): raise Http404 return obj.get_abilities(request.user).get(view.action, False) + + +class CanMuteParticipant(permissions.BasePermission): + """ + Grant muting rights based on role or room configuration. + + - Admins and owners can always mute. + - When `everyone_can_mute` is enabled on the room, any participant + currently in the room (proven by a valid LiveKit token for that room) + can mute. + """ + + def has_object_permission(self, request, view, obj): + """Check if the requesting user is allowed to mute a participant in the given room.""" + + is_livekit_token_auth = request.auth and hasattr(request.auth, "video") + + # Always allow admins/owners when authenticated with session cookie + if not is_livekit_token_auth and obj.is_administrator_or_owner(request.user): + return True + + everyone_can_mute = obj.configuration.get("everyone_can_mute", True) + if not everyone_can_mute: + return False + + if not is_livekit_token_auth: + return False + + # LiveKit token scoped to this room + return request.auth.video.room == str(obj.id) diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index e883e1ea..764dda16 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -33,6 +33,7 @@ from rest_framework import ( from rest_framework import ( status as drf_status, ) +from rest_framework.settings import api_settings from core import enums, models, utils from core.api.filters import ListFileFilter @@ -614,7 +615,11 @@ class RoomViewSet( methods=["post"], url_path="mute-participant", url_name="mute-participant", - permission_classes=[permissions.HasPrivilegesOnRoom], + permission_classes=[permissions.CanMuteParticipant], + authentication_classes=[ + LiveKitTokenAuthentication, + *api_settings.DEFAULT_AUTHENTICATION_CLASSES, + ], ) def mute_participant(self, request, pk=None): # pylint: disable=unused-argument """Mute a specific track for a participant in the room.""" diff --git a/src/backend/core/tests/rooms/test_api_rooms_participants_management.py b/src/backend/core/tests/rooms/test_api_rooms_participants_management.py index f9d17024..bc72e135 100644 --- a/src/backend/core/tests/rooms/test_api_rooms_participants_management.py +++ b/src/backend/core/tests/rooms/test_api_rooms_participants_management.py @@ -8,6 +8,7 @@ import random from unittest import mock from uuid import uuid4 +from django.contrib.auth.models import AnonymousUser from django.core.exceptions import SuspiciousOperation from django.urls import reverse @@ -16,6 +17,7 @@ from livekit.api import TwirpError from rest_framework import status from rest_framework.test import APIClient +from core import utils from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory from core.services.lobby import LobbyService @@ -31,8 +33,8 @@ def mock_livekit_client(): yield mock_client -def test_mute_participant_success(mock_livekit_client): - """Test successful participant muting.""" +def test_mute_participant_success_as_admin(mock_livekit_client): + """Admins and owners should be able to mute without a LiveKit token.""" client = APIClient() room = RoomFactory() user = UserFactory() @@ -41,10 +43,12 @@ def test_mute_participant_success(mock_livekit_client): ) client.force_authenticate(user=user) - payload = {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"} - url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) - response = client.post(url, payload, format="json") + response = client.post( + url, + {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, + format="json", + ) assert response.status_code == status.HTTP_200_OK assert response.data == {"status": "success"} @@ -53,23 +57,131 @@ def test_mute_participant_success(mock_livekit_client): mock_livekit_client.aclose.assert_called_once() -def test_mute_participant_forbidden_without_access(): - """Test mute participant returns 403 when user lacks room privileges.""" +def test_mute_participant_anonymous_no_token_forbidden(mock_livekit_client): + """Should forbid muting when user is anonymous and no LiveKit token.""" client = APIClient() room = RoomFactory() - user = UserFactory() # User without UserResourceAccess - client.force_authenticate(user=user) - - payload = {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"} url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) - response = client.post(url, payload, format="json") + response = client.post( + url, + {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, + format="json", + ) assert response.status_code == status.HTTP_403_FORBIDDEN + mock_livekit_client.room.mute_published_track.assert_not_called() + + +def test_mute_participant_with_livekit_token_for_this_room(mock_livekit_client): + """Should allow muting when the LiveKit token is scoped to this room.""" + client = APIClient() + room = RoomFactory() + + user = AnonymousUser() + token = utils.generate_token(str(room.id), user, is_admin_or_owner=False) + + url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) + response = client.post( + url, + {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + assert response.status_code == status.HTTP_200_OK + assert response.data == {"status": "success"} + + mock_livekit_client.room.mute_published_track.assert_called_once() + + +def test_mute_participant_with_livekit_token_for_another_room_forbidden( + mock_livekit_client, +): + """Should forbid muting when the LiveKit token is scoped to a different room.""" + + client = APIClient() + target_room = RoomFactory() + other_room = RoomFactory() + + user = AnonymousUser() + token = utils.generate_token(str(other_room.id), user, is_admin_or_owner=False) + + url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id}) + response = client.post( + url, + {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + mock_livekit_client.room.mute_published_track.assert_not_called() + + +def test_mute_participant_authenticated_no_role_no_token_forbidden(mock_livekit_client): + """Should forbid muting when user has no room role and no LiveKit token.""" + client = APIClient() + room = RoomFactory() # everyone_can_mute defaults to True + user = UserFactory() # no UserResourceAccess for this room + client.force_authenticate(user=user) + + url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) + response = client.post( + url, + {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, + format="json", + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + mock_livekit_client.room.mute_published_track.assert_not_called() + + +def test_mute_participant_everyone_can_mute_disabled_blocks_non_admin( + mock_livekit_client, +): + """Should forbid muting when everyone_can_mute is False, even with a LiveKit token.""" + client = APIClient() + room = RoomFactory(configuration={"everyone_can_mute": False}) + + user = AnonymousUser() + token = utils.generate_token(str(room.id), user, is_admin_or_owner=False) + + url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) + response = client.post( + url, + {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + mock_livekit_client.room.mute_published_track.assert_not_called() + + +def test_mute_participant_everyone_can_mute_disabled_allows_admin(mock_livekit_client): + """Should allow admins and owners to mute when everyone_can_mute is False.""" + client = APIClient() + room = RoomFactory(configuration={"everyone_can_mute": False}) + user = UserFactory() + UserResourceAccessFactory( + resource=room, user=user, role=random.choice(["administrator", "owner"]) + ) + client.force_authenticate(user=user) + + url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) + response = client.post( + url, + {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, + format="json", + ) + + assert response.status_code == status.HTTP_200_OK + mock_livekit_client.room.mute_published_track.assert_called_once() def test_mute_participant_invalid_payload(): - """Test mute participant with invalid payload.""" + """Should reject muting when the payload is invalid.""" client = APIClient() room = RoomFactory() user = UserFactory() @@ -78,16 +190,16 @@ def test_mute_participant_invalid_payload(): ) client.force_authenticate(user=user) - payload = {"participant_identity": "invalid-uuid", "track_sid": ""} - url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) - response = client.post(url, payload, format="json") + response = client.post( + url, {"participant_identity": "invalid-uuid", "track_sid": ""}, format="json" + ) assert response.status_code == status.HTTP_400_BAD_REQUEST def test_mute_participant_unexpected_twirp_error(mock_livekit_client): - """Test mute participant when LiveKit API raises TwirpError.""" + """Should return 500 when the LiveKit API raises a TwirpError.""" client = APIClient() mock_livekit_client.room.mute_published_track.side_effect = TwirpError( @@ -101,10 +213,12 @@ def test_mute_participant_unexpected_twirp_error(mock_livekit_client): ) client.force_authenticate(user=user) - payload = {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"} - url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) - response = client.post(url, payload, format="json") + response = client.post( + url, + {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, + format="json", + ) assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR assert response.data == {"error": "Failed to mute participant"} @@ -112,6 +226,147 @@ def test_mute_participant_unexpected_twirp_error(mock_livekit_client): mock_livekit_client.aclose.assert_called_once() +def test_mute_participant_participant_not_found(mock_livekit_client): + """Should return 404 when the participant does not exist in the room.""" + client = APIClient() + + mock_livekit_client.room.mute_published_track.side_effect = TwirpError( + msg="participant does not exist", code="not_found", status=404 + ) + + room = RoomFactory() + user = UserFactory() + UserResourceAccessFactory( + resource=room, user=user, role=random.choice(["administrator", "owner"]) + ) + client.force_authenticate(user=user) + + url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) + response = client.post( + url, + {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, + format="json", + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.data == {"error": "Participant not found"} + + mock_livekit_client.aclose.assert_called_once() + + +def test_mute_participant_management_exception(mock_livekit_client): + """Should return 500 when ParticipantsManagement raises an unexpected error.""" + client = APIClient() + + mock_livekit_client.room.mute_published_track.side_effect = TwirpError( + msg="boom", code="internal", status=503 + ) + + room = RoomFactory() + user = UserFactory() + UserResourceAccessFactory( + resource=room, user=user, role=random.choice(["administrator", "owner"]) + ) + client.force_authenticate(user=user) + + url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) + response = client.post( + url, + {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, + format="json", + ) + + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + assert response.data == {"error": "Failed to mute participant"} + + mock_livekit_client.aclose.assert_called_once() + + +def test_mute_participant_admin_with_token_for_this_room(mock_livekit_client): + """Should allow muting when user is admin and LiveKit token is scoped to this room.""" + client = APIClient() + room = RoomFactory() + user = UserFactory() + UserResourceAccessFactory( + resource=room, user=user, role=random.choice(["administrator", "owner"]) + ) + # Token identity matches the admin user so LiveKitTokenAuthentication + # resolves request.user back to the admin. + token = utils.generate_token(str(room.id), user, is_admin_or_owner=True) + + url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) + response = client.post( + url, + {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + assert response.status_code == status.HTTP_200_OK + assert response.data == {"status": "success"} + + mock_livekit_client.room.mute_published_track.assert_called_once() + + +def test_mute_participant_admin_with_token_for_another_room(mock_livekit_client): + """Should not allow muting when user is admin and the LiveKit token is for another room.""" + client = APIClient() + target_room = RoomFactory() + other_room = RoomFactory() + user = UserFactory() + UserResourceAccessFactory( + resource=target_room, + user=user, + role=random.choice(["administrator", "owner"]), + ) + # Token is scoped to a DIFFERENT room, and admin status must only be + # honored when established via session, never via a LiveKit + # token, which can be replayed off-host. + token = utils.generate_token(str(other_room.id), user, is_admin_or_owner=True) + + url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id}) + response = client.post( + url, + {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.data == { + "detail": "You do not have permission to perform this action." + } + + mock_livekit_client.room.mute_published_track.assert_not_called() + + +def test_mute_participant_admin_token_replayed_does_not_grant_admin( + mock_livekit_client, +): + """Should forbid muting when a LiveKit token issued for an admin is passed without a session.""" + client = APIClient() + room = RoomFactory(configuration={"everyone_can_mute": False}) + admin_user = UserFactory() + UserResourceAccessFactory( + resource=room, + user=admin_user, + role=random.choice(["administrator", "owner"]), + ) + # The token is the only credential. + token = utils.generate_token(str(room.id), admin_user, is_admin_or_owner=True) + + url = reverse("rooms-mute-participant", kwargs={"pk": room.id}) + response = client.post( + url, + {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token}", + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + mock_livekit_client.room.mute_published_track.assert_not_called() + + def test_update_participant_success(mock_livekit_client): """Test successful participant update.""" client = APIClient()