(backend) add endpoint to update a participant role during a meeting

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.
This commit is contained in:
lebaudantoine
2026-07-07 18:05:21 +02:00
parent 10e8bca5a4
commit 10fd18caf8
4 changed files with 557 additions and 0 deletions
+9
View File
@@ -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"]
+51
View File
@@ -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"],
+178
View File
@@ -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
@@ -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