From 10fd18caf8edba41fac9a6c8117dce28f8118c5e Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Tue, 7 Jul 2026 18:05:21 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20add=20endpoint=20to=20upda?= =?UTF-8?q?te=20a=20participant=20role=20during=20a=20meeting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an endpoint that allows updating a user's role while in a meeting. The goal is to let users promote other connected participants to admin or moderator, so the burden of administrating a meeting can be shared. --- src/backend/core/api/serializers.py | 9 + src/backend/core/api/viewsets.py | 51 +++ src/backend/core/services/room_roles.py | 178 ++++++++++ .../test_api_rooms_update_participant_role.py | 319 ++++++++++++++++++ 4 files changed, 557 insertions(+) create mode 100644 src/backend/core/services/room_roles.py create mode 100644 src/backend/core/tests/rooms/test_api_rooms_update_participant_role.py diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 4a7a5637..0e1b5323 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -312,6 +312,15 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer): ) +class ParticipantRoleSerializer(BaseParticipantsManagementSerializer): + """Validate an in-meeting role change (promotion/demotion) request.""" + + role = serializers.ChoiceField( + choices=[models.RoleChoices.MEMBER, models.RoleChoices.ADMIN], + help_text="Target role. Ownership cannot be granted this way.", + ) + + TrackSource = Literal["camera", "microphone", "screen_share", "screen_share_audio"] diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index ab8b7299..5f1ad94f 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -88,6 +88,10 @@ from core.services.room_management import ( RoomManagementException, RoomNotFoundException, ) +from core.services.room_roles import ( + RoomRoleError, + RoomRoleService, +) from core.services.subtitle import SubtitleException, SubtitleService from core.tasks.file import process_file_deletion @@ -639,6 +643,53 @@ class RoomViewSet( status=drf_status.HTTP_200_OK, ) + @decorators.action( + detail=True, + methods=["post"], + url_path="update-participant-role", + permission_classes=[ + permissions.HasPrivilegesOnRoom, + permissions.IsPresentInMeeting, + ], + ) + def update_participant_role(self, request, pk=None): # pylint: disable=unused-argument + """Promote or demote a participant currently connected to the meeting. + + Requires the requester to be session-authenticated, have privileges + (admin/owner) on the room, and be connected to the meeting. + + If the target participant has a user account, the role is persisted + (`ResourceAccess`) then mirrored to their LiveKit attributes. + + If the participant is anonymous, the promotion will fail. + """ + + room = self.get_object() + + serializer = serializers.ParticipantRoleSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + participant_identity = serializer.validated_data["participant_identity"] + role = serializer.validated_data["role"] + + if str(request.user.sub) == str(participant_identity): + return drf_response.Response( + {"error": "You cannot change your own role."}, + status=drf_status.HTTP_403_FORBIDDEN, + ) + + try: + result = RoomRoleService().set_participant_role( + room=room, + participant_identity=participant_identity, + role=role, + actor=request.user, + ) + except RoomRoleError as e: + return drf_response.Response({"error": str(e)}, status=e.status_code) + + return drf_response.Response(result, status=drf_status.HTTP_200_OK) + @decorators.action( detail=True, methods=["post"], diff --git a/src/backend/core/services/room_roles.py b/src/backend/core/services/room_roles.py new file mode 100644 index 00000000..f7d7a0fa --- /dev/null +++ b/src/backend/core/services/room_roles.py @@ -0,0 +1,178 @@ +"""Room role management service. + +Single entry point for changing a user's role on a room, used by: +- the in-meeting endpoint (promote/demote a connected participant) +- (more to come soon) + +`ResourceAccess` is the source of truth. The LiveKit `room_admin` +participant attribute is only a projection of it, synced best-effort. +""" + +from logging import getLogger +from uuid import UUID + +from core import models +from core.services.participants_management import ( + ParticipantNotFoundException, + ParticipantsManagement, + ParticipantsManagementException, +) + +logger = getLogger(__name__) + + +class RoomRoleError(Exception): + """Base exception for room role management errors.""" + + status_code = 400 + + +class SelfActionError(RoomRoleError): + """Raised when a user tries to change their own role.""" + + status_code = 403 + + +class OwnerRoleError(RoomRoleError): + """Raised when trying to demote an owner or grant ownership.""" + + status_code = 403 + + +class ParticipantNotInMeetingError(RoomRoleError): + """Raised when the target participant is not connected to the meeting.""" + + status_code = 404 + + +class UserNotFoundError(RoomRoleError): + """Raised when the target participant has no user account in database.""" + + status_code = 404 + + +ASSIGNABLE_ROLES = (models.RoleChoices.MEMBER, models.RoleChoices.ADMIN) + + +class RoomRoleService: + """Manage promotion and demotion of room co-hosts.""" + + def set_role( + self, room: models.Room, user: models.User, role: str, actor: models.User + ): + """Persist `role` for `user` on `room`, idempotently and atomically. + + Returns the up-to-date `ResourceAccess`. Never grants or removes + ownership: granting OWNER is refused, and an existing OWNER access + is never modified. + """ + + if role not in ASSIGNABLE_ROLES: + raise OwnerRoleError("Ownership cannot be granted through this action.") + + if actor is not None and user == actor: + raise SelfActionError("You cannot change your own role.") + + access, created = models.ResourceAccess.objects.get_or_create( + resource=room, + user=user, + defaults={"role": role}, + ) + + if created: + return access + + if access.role == models.RoleChoices.OWNER: + raise OwnerRoleError("Room owners cannot be demoted.") + + if access.role != role: + access.role = role + access.save(update_fields=["role", "updated_at"]) + + return access + + def set_participant_role( + self, + room: models.Room, + participant_identity: UUID, + role: str, + actor: models.User, + ): + """Change the role of a participant currently connected to the meeting. + + - The participant must be connected (checked against LiveKit). + - The participant must map to a user account. + - The role is persisted in DB then mirrored to LiveKit. + + Returns a dict: {"role", "livekit_synced"}. + """ + + room_name = str(room.pk) + participants_management = ParticipantsManagement() + + try: + is_in_meeting = participants_management.check_if_in_meeting( + room_name=room_name, identity=str(participant_identity) + ) + except ParticipantNotFoundException as e: + raise ParticipantNotInMeetingError( + "Participant is not connected to this meeting." + ) from e + + if not is_in_meeting: + raise ParticipantNotInMeetingError( + "Participant is not connected to this meeting." + ) + + user = models.User.objects.filter(sub=participant_identity).first() + + if user is None: + raise UserNotFoundError( + "This participant has no user account and cannot be assigned a role." + ) + + # Source of truth first: even if the LiveKit sync below fails, + # the role is real and any fresh token will carry it. + self.set_role(room=room, user=user, role=role, actor=actor) + + livekit_synced = self._sync_livekit_role( + room_name=room_name, + participant_identity=str(participant_identity), + is_admin=role == models.RoleChoices.ADMIN, + ) + + return { + "role": role, + "livekit_synced": livekit_synced, + } + + @staticmethod + def _sync_livekit_role(room_name: str, participant_identity: str, is_admin: bool): + """Mirror the role to the participant's LiveKit attributes. + + Best-effort: returns False on failure instead of raising, so callers + can report a partial success. Re-running the action re-syncs. + """ + try: + ParticipantsManagement().update( + room_name=room_name, + identity=participant_identity, + attributes={"room_admin": "true" if is_admin else "false"}, + ) + except ParticipantNotFoundException: + # The participant left between the presence check and the update: + # harmless, the DB state (if any) remains authoritative. + logger.info( + "Participant %s left room %s before role sync", + participant_identity, + room_name, + ) + return False + except ParticipantsManagementException: + logger.exception( + "Could not sync role to LiveKit for participant %s in room %s", + participant_identity, + room_name, + ) + return False + return True diff --git a/src/backend/core/tests/rooms/test_api_rooms_update_participant_role.py b/src/backend/core/tests/rooms/test_api_rooms_update_participant_role.py new file mode 100644 index 00000000..956fd5f3 --- /dev/null +++ b/src/backend/core/tests/rooms/test_api_rooms_update_participant_role.py @@ -0,0 +1,319 @@ +""" +Test rooms API endpoints in the Meet core app: update-participant-role. +""" + +# pylint: disable=redefined-outer-name,unused-argument + +import uuid +from unittest import mock + +import pytest +from rest_framework.test import APIClient + +from ...factories import RoomFactory, UserFactory +from ...models import ResourceAccess, RoleChoices +from ...services.participants_management import ParticipantNotFoundException + +pytestmark = pytest.mark.django_db + + +def test_update_participant_role_anonymous(): + """Anonymous requesters are rejected.""" + client = APIClient() + room = RoomFactory() + + response = client.post( + f"/api/v1.0/rooms/{room.id}/update-participant-role/", + {"participant_identity": "some-identity", "role": "administrator"}, + format="json", + ) + + assert response.status_code == 401 + + +def test_update_participant_role_requires_privileges(): + """A simple member cannot promote other participants.""" + client = APIClient() + user = UserFactory() + room = RoomFactory(users=[(user, RoleChoices.MEMBER)]) + client.force_login(user) + + response = client.post( + f"/api/v1.0/rooms/{room.id}/update-participant-role/", + {"participant_identity": "some-identity", "role": "administrator"}, + format="json", + ) + + assert response.status_code == 403 + + +@mock.patch("core.api.permissions.ParticipantsManagement") +def test_update_participant_role_requester_not_in_meeting(mock_perm_pm): + """An admin who is not connected to the meeting is rejected.""" + mock_perm_pm.return_value.check_if_in_meeting.return_value = False + client = APIClient() + user = UserFactory() + room = RoomFactory(users=[(user, RoleChoices.ADMIN)]) + client.force_login(user) + + response = client.post( + f"/api/v1.0/rooms/{room.id}/update-participant-role/", + {"participant_identity": "some-identity", "role": "administrator"}, + format="json", + ) + + assert response.status_code == 403 + mock_perm_pm.return_value.check_if_in_meeting.assert_called_once_with( + room_name=str(room.pk), identity=str(user.sub) + ) + + +@mock.patch("core.api.permissions.ParticipantsManagement") +def test_update_participant_role_cannot_target_self(mock_perm_pm): + """Requesters cannot change their own role.""" + mock_perm_pm.return_value.check_if_in_meeting.return_value = True + client = APIClient() + user = UserFactory(sub=uuid.uuid4()) + room = RoomFactory(users=[(user, RoleChoices.ADMIN)]) + client.force_login(user) + + response = client.post( + f"/api/v1.0/rooms/{room.id}/update-participant-role/", + {"participant_identity": user.sub, "role": "member"}, + format="json", + ) + + assert response.status_code == 403 + assert response.json() == {"error": "You cannot change your own role."} + + +@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role") +@mock.patch("core.services.room_roles.ParticipantsManagement") +@mock.patch("core.api.permissions.ParticipantsManagement") +def test_update_participant_role_promotes_authenticated_target( + mock_perm_pm, mock_svc_pm, mock_sync +): + """Promoting a connected, authenticated participant persists the role.""" + mock_perm_pm.return_value.check_if_in_meeting.return_value = True + mock_svc_pm.return_value.check_if_in_meeting.return_value = True + mock_sync.return_value = True + + client = APIClient() + admin = UserFactory() + target = UserFactory(sub=uuid.uuid4()) + room = RoomFactory(users=[(admin, RoleChoices.OWNER)]) + client.force_login(admin) + + response = client.post( + f"/api/v1.0/rooms/{room.id}/update-participant-role/", + {"participant_identity": str(target.sub), "role": "administrator"}, + format="json", + ) + + assert response.status_code == 200 + assert response.json() == { + "role": "administrator", + "livekit_synced": True, + } + access = ResourceAccess.objects.get(resource=room, user=target) + assert access.role == RoleChoices.ADMIN + mock_sync.assert_called_once_with( + room_name=str(room.pk), + participant_identity=str(target.sub), + is_admin=True, + ) + + +@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role") +@mock.patch("core.services.room_roles.ParticipantsManagement") +@mock.patch("core.api.permissions.ParticipantsManagement") +def test_update_participant_role_demotes_authenticated_target( + mock_perm_pm, mock_svc_pm, mock_sync +): + """Demoting a connected admin back to member updates the access row.""" + mock_perm_pm.return_value.check_if_in_meeting.return_value = True + mock_svc_pm.return_value.check_if_in_meeting.return_value = True + mock_sync.return_value = True + + client = APIClient() + admin = UserFactory() + target = UserFactory(sub=uuid.uuid4()) + room = RoomFactory(users=[(admin, RoleChoices.OWNER), (target, RoleChoices.ADMIN)]) + client.force_login(admin) + + response = client.post( + f"/api/v1.0/rooms/{room.id}/update-participant-role/", + {"participant_identity": str(target.sub), "role": "member"}, + format="json", + ) + + assert response.status_code == 200 + access = ResourceAccess.objects.get(resource=room, user=target) + assert access.role == RoleChoices.MEMBER + + +@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role") +@mock.patch("core.services.room_roles.ParticipantsManagement") +@mock.patch("core.api.permissions.ParticipantsManagement") +def test_update_participant_role_cannot_demote_owner( + mock_perm_pm, mock_svc_pm, mock_sync +): + """Room owners can never be demoted.""" + mock_perm_pm.return_value.check_if_in_meeting.return_value = True + mock_svc_pm.return_value.check_if_in_meeting.return_value = True + + client = APIClient() + admin = UserFactory() + owner = UserFactory(sub=uuid.uuid4()) + room = RoomFactory(users=[(admin, RoleChoices.ADMIN), (owner, RoleChoices.OWNER)]) + client.force_login(admin) + + response = client.post( + f"/api/v1.0/rooms/{room.id}/update-participant-role/", + {"participant_identity": str(owner.sub), "role": "member"}, + format="json", + ) + + assert response.status_code == 403 + assert response.json() == {"error": "Room owners cannot be demoted."} + assert ( + ResourceAccess.objects.get(resource=room, user=owner).role == RoleChoices.OWNER + ) + mock_sync.assert_not_called() + + +@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role") +@mock.patch("core.services.room_roles.ParticipantsManagement") +@mock.patch("core.api.permissions.ParticipantsManagement") +def test_update_participant_role_anonymous_target_is_ephemeral( + mock_perm_pm, mock_svc_pm, mock_sync +): + """Promoting an anonymous participant should not be possible.""" + mock_perm_pm.return_value.check_if_in_meeting.return_value = True + mock_svc_pm.return_value.check_if_in_meeting.return_value = True + mock_sync.return_value = True + + client = APIClient() + admin = UserFactory() + room = RoomFactory(users=[(admin, RoleChoices.ADMIN)]) + client.force_login(admin) + + anonymous_identity = uuid.uuid4() + response = client.post( + f"/api/v1.0/rooms/{room.id}/update-participant-role/", + {"participant_identity": anonymous_identity, "role": "administrator"}, + format="json", + ) + + assert response.status_code == 404 + assert response.json() == { + "error": "This participant has no user account and cannot be assigned a role." + } + assert not ResourceAccess.objects.filter(resource=room).exclude(user=admin).exists() + + +@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role") +@mock.patch("core.services.room_roles.ParticipantsManagement") +@mock.patch("core.api.permissions.ParticipantsManagement") +def test_update_participant_role_target_not_in_meeting( + mock_perm_pm, mock_svc_pm, mock_sync +): + """Only connected participants can be promoted or demoted.""" + mock_perm_pm.return_value.check_if_in_meeting.return_value = True + mock_svc_pm.return_value.check_if_in_meeting.side_effect = ( + ParticipantNotFoundException("Participant does not exist") + ) + + client = APIClient() + admin = UserFactory() + target = UserFactory(sub=uuid.uuid4()) + room = RoomFactory(users=[(admin, RoleChoices.ADMIN)]) + client.force_login(admin) + + response = client.post( + f"/api/v1.0/rooms/{room.id}/update-participant-role/", + {"participant_identity": str(target.sub), "role": "administrator"}, + format="json", + ) + + assert response.status_code == 404 + assert not ResourceAccess.objects.filter(resource=room, user=target).exists() + mock_sync.assert_not_called() + + +@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role") +@mock.patch("core.services.room_roles.ParticipantsManagement") +@mock.patch("core.api.permissions.ParticipantsManagement") +def test_update_participant_role_is_idempotent_and_resyncs( + mock_perm_pm, mock_svc_pm, mock_sync +): + """Promoting an existing admin succeeds and still re-syncs LiveKit.""" + mock_perm_pm.return_value.check_if_in_meeting.return_value = True + mock_svc_pm.return_value.check_if_in_meeting.return_value = True + mock_sync.return_value = True + + client = APIClient() + admin = UserFactory() + target = UserFactory(sub=uuid.uuid4()) + room = RoomFactory(users=[(admin, RoleChoices.OWNER), (target, RoleChoices.ADMIN)]) + client.force_login(admin) + + response = client.post( + f"/api/v1.0/rooms/{room.id}/update-participant-role/", + {"participant_identity": str(target.sub), "role": "administrator"}, + format="json", + ) + + assert response.status_code == 200 + mock_sync.assert_called_once() + + +@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role") +@mock.patch("core.services.room_roles.ParticipantsManagement") +@mock.patch("core.api.permissions.ParticipantsManagement") +def test_update_participant_role_livekit_failure_reports_partial_success( + mock_perm_pm, mock_svc_pm, mock_sync +): + """A LiveKit sync failure does not lose the persisted role.""" + mock_perm_pm.return_value.check_if_in_meeting.return_value = True + mock_svc_pm.return_value.check_if_in_meeting.return_value = True + mock_sync.return_value = False + + client = APIClient() + admin = UserFactory() + target = UserFactory(sub=uuid.uuid4()) + room = RoomFactory(users=[(admin, RoleChoices.OWNER)]) + client.force_login(admin) + + response = client.post( + f"/api/v1.0/rooms/{room.id}/update-participant-role/", + {"participant_identity": str(target.sub), "role": "administrator"}, + format="json", + ) + + assert response.status_code == 200 + assert response.json() == { + "role": "administrator", + "livekit_synced": False, + } + assert ( + ResourceAccess.objects.get(resource=room, user=target).role == RoleChoices.ADMIN + ) + + +@mock.patch("core.api.permissions.ParticipantsManagement") +def test_update_participant_role_rejects_owner_role(mock_perm_pm): + """The owner role can never be granted through this endpoint.""" + client = APIClient() + admin = UserFactory() + room = RoomFactory(users=[(admin, RoleChoices.ADMIN)]) + client.force_login(admin) + + mock_perm_pm.return_value.check_if_in_meeting.return_value = True + response = client.post( + f"/api/v1.0/rooms/{room.id}/update-participant-role/", + {"participant_identity": "some-identity", "role": "owner"}, + format="json", + ) + + assert response.status_code == 400