♻️(backend) pass the participant role in the LiveKit token

The backend previously passed an abstract is_admin_or_owner boolean
flag in the LiveKit token. That kept the frontend minimalistic and
saved it from having to handle role comparisons.

As we introduce more features that need to distinguish between the
room owner and admins, refactor the token to carry the role
directly. The frontend can then derive the relevant flags from a
richer piece of information.
This commit is contained in:
lebaudantoine
2026-07-07 19:21:12 +02:00
parent 10fd18caf8
commit 72f40ecf48
13 changed files with 54 additions and 48 deletions
+1 -1
View File
@@ -183,7 +183,7 @@ class RoomSerializer(serializers.ModelSerializer):
user=request.user,
username=username,
configuration=output["configuration"],
is_admin_or_owner=is_admin_or_owner,
role=role,
)
else:
del output["pin_code"]
-2
View File
@@ -162,7 +162,6 @@ class LobbyService:
username=username,
color=participant.color,
configuration=room.configuration,
is_admin_or_owner=False,
participant_id=participant_id,
)
return participant, livekit_config
@@ -183,7 +182,6 @@ class LobbyService:
username=username,
color=participant.color,
configuration=room.configuration,
is_admin_or_owner=False,
participant_id=participant_id,
)
+4 -4
View File
@@ -4,7 +4,7 @@ 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`
`ResourceAccess` is the source of truth. The LiveKit `room_role`
participant attribute is only a projection of it, synced best-effort.
"""
@@ -138,7 +138,7 @@ class RoomRoleService:
livekit_synced = self._sync_livekit_role(
room_name=room_name,
participant_identity=str(participant_identity),
is_admin=role == models.RoleChoices.ADMIN,
role=str(role),
)
return {
@@ -147,7 +147,7 @@ class RoomRoleService:
}
@staticmethod
def _sync_livekit_role(room_name: str, participant_identity: str, is_admin: bool):
def _sync_livekit_role(room_name: str, participant_identity: str, role: str):
"""Mirror the role to the participant's LiveKit attributes.
Best-effort: returns False on failure instead of raising, so callers
@@ -157,7 +157,7 @@ class RoomRoleService:
ParticipantsManagement().update(
room_name=room_name,
identity=participant_identity,
attributes={"room_admin": "true" if is_admin else "false"},
attributes={"room_role": role},
)
except ParticipantNotFoundException:
# The participant left between the presence check and the update:
@@ -80,7 +80,7 @@ def test_mute_participant_with_livekit_token_for_this_room(mock_livekit_client):
room = RoomFactory()
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
token = utils.generate_token(str(room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
@@ -106,7 +106,7 @@ def test_mute_participant_with_livekit_token_for_another_room_forbidden(
other_room = RoomFactory()
user = AnonymousUser()
token = utils.generate_token(str(other_room.id), user, is_admin_or_owner=False)
token = utils.generate_token(str(other_room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
response = client.post(
@@ -146,7 +146,7 @@ def test_mute_participant_everyone_can_mute_disabled_blocks_non_admin(
room = RoomFactory(configuration={"everyone_can_mute": False})
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
token = utils.generate_token(str(room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
@@ -293,7 +293,7 @@ def test_mute_participant_admin_with_token_for_this_room(mock_livekit_client):
)
# 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)
token = utils.generate_token(str(room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
@@ -323,7 +323,7 @@ def test_mute_participant_admin_with_token_for_another_room(mock_livekit_client)
# 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)
token = utils.generate_token(str(other_room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
response = client.post(
@@ -354,7 +354,7 @@ def test_mute_participant_admin_token_replayed_does_not_grant_admin(
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)
token = utils.generate_token(str(room.id), admin_user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
@@ -374,7 +374,7 @@ def test_mute_participant_livekit_token_triggers_presence_check(mock_livekit_cli
room = RoomFactory()
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
token = utils.generate_token(str(room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
@@ -405,7 +405,7 @@ def test_mute_participant_livekit_token_presence_check_returns_participant(
)
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
token = utils.generate_token(str(room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
@@ -433,7 +433,7 @@ def test_mute_participant_livekit_token_presence_check_participant_not_found(
)
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
token = utils.generate_token(str(room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
@@ -462,7 +462,7 @@ def test_mute_participant_livekit_token_presence_check_twirp_error_forbidden(
)
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
token = utils.generate_token(str(room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
@@ -12,7 +12,7 @@ import pytest
from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory, UserResourceAccessFactory
from ...models import RoomAccessLevel
from ...models import RoomAccessLevel, RoleChoices
pytestmark = pytest.mark.django_db
@@ -278,7 +278,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
username=None,
color=None,
sources=["camera"],
is_admin_or_owner=False,
role=None,
participant_id=None,
)
@@ -330,7 +330,7 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
username=None,
color=None,
sources=None,
is_admin_or_owner=False,
role=None,
participant_id=None,
)
@@ -418,7 +418,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
username=None,
color=None,
sources=["camera"],
is_admin_or_owner=False,
role=str(RoleChoices.MEMBER),
participant_id=None,
)
@@ -511,6 +511,6 @@ def test_api_rooms_retrieve_administrators(
username=None,
color=None,
sources=None,
is_admin_or_owner=True,
role=str(user_access.role),
participant_id=None,
)
@@ -120,7 +120,7 @@ def test_update_participant_role_promotes_authenticated_target(
mock_sync.assert_called_once_with(
room_name=str(room.pk),
participant_identity=str(target.sub),
is_admin=True,
role="administrator",
)
@@ -266,7 +266,6 @@ def test_request_entry_public_room(
username=username,
color=participant.color,
configuration=room.configuration,
is_admin_or_owner=False,
participant_id="test-participant-id",
)
@@ -305,7 +304,6 @@ def test_request_entry_trusted_room(
username=username,
color=participant.color,
configuration=room.configuration,
is_admin_or_owner=False,
participant_id="test-participant-id",
)
@@ -400,7 +398,6 @@ def test_request_entry_accepted_participant(
username=username,
color="#123456",
configuration=room.configuration,
is_admin_or_owner=False,
participant_id="test-participant-id",
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
+7 -6
View File
@@ -66,7 +66,7 @@ def generate_token(
username: Optional[str] = None,
color: Optional[str] = None,
sources: Optional[List[str]] = None,
is_admin_or_owner: bool = False,
role: Optional[str] = None,
participant_id: Optional[str] = None,
) -> str:
"""Generate a LiveKit access token for a user in a specific room.
@@ -80,7 +80,7 @@ def generate_token(
If none, a value will be generated
sources: (Optional[List[str]]): List of media sources the user can publish
If none, defaults to LIVEKIT_DEFAULT_SOURCES.
is_admin_or_owner (bool): Whether user has admin privileges
role (Optional[str]): Room's access role if any
participant_id (Optional[str]): Stable identifier for anonymous users;
used as identity when user.is_anonymous.
@@ -88,6 +88,7 @@ def generate_token(
str: The LiveKit JWT access token.
"""
is_admin_or_owner = role in ("owner", "administrator")
if is_admin_or_owner:
sources = settings.LIVEKIT_DEFAULT_SOURCES
@@ -128,7 +129,7 @@ def generate_token(
.with_identity(identity)
.with_name(display_name)
.with_attributes(
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
{"color": color, "room_role": role }
)
)
@@ -139,7 +140,7 @@ def generate_livekit_config(
room_id: str,
user,
username: str,
is_admin_or_owner: bool,
role: Optional[str] = None,
color: Optional[str] = None,
configuration: Optional[dict] = None,
participant_id: Optional[str] = None,
@@ -150,7 +151,7 @@ def generate_livekit_config(
room_id: Room identifier
user: User instance requesting access
username: Display name in room
is_admin_or_owner (bool): Whether the user has admin/owner privileges for this room.
role (str): Room's access role if any
color (Optional[str]): Optional color to associate with the participant.
configuration (Optional[dict]): Room configuration dict that can override default settings.
participant_id (Optional[str]): Stable identifier for anonymous users;
@@ -173,7 +174,7 @@ def generate_livekit_config(
username=username,
color=color,
sources=sources,
is_admin_or_owner=is_admin_or_owner,
role=role,
participant_id=participant_id,
),
}
@@ -5,7 +5,7 @@ import { Text } from '@/primitives/Text'
import { useTranslation } from 'react-i18next'
import { Avatar } from '@/components/Avatar'
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
import { getParticipantIsRoomAdmin } from '@/features/rooms/utils/getParticipantIsRoomAdmin'
import { getParticipantIsRoomOwner } from '@/features/rooms/utils/getParticipantIsRoomAdminOrOwner'
import { type LocalParticipant, type Participant, Track } from 'livekit-client'
import { isLocal } from '@/utils/livekit'
import {
@@ -146,7 +146,7 @@ export const ParticipantRow = ({ participant }: ParticipantListItemProps) => {
</span>
)}
</Text>
{getParticipantIsRoomAdmin(participant) && (
{getParticipantIsRoomOwner(participant) && (
<Text variant="xsNote">{t('participants.host')}</Text>
)}
</VStack>
@@ -28,3 +28,5 @@ export type ApiRoom = {
livekit?: ApiLiveKit
configuration?: RoomConfiguration
}
export type ParticipantRole = 'member' | 'administrator' | 'owner'
@@ -8,7 +8,7 @@ import { useRemoteParticipants } from '@livekit/components-react'
import { useUpdateParticipantsPermissions } from '@/features/rooms/api/updateParticipantsPermissions'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { isSubsetOf } from '@/features/rooms/utils/isSubsetOf'
import { getParticipantIsRoomAdmin } from '@/features/rooms/utils/getParticipantIsRoomAdmin'
import { getParticipantIsRoomAdminOrOwner } from '@/features/rooms/utils/getParticipantIsRoomAdminOrOwner'
import Source = Track.Source
import {
NotificationType,
@@ -48,7 +48,7 @@ export const usePublishSourcesManager = () => {
})
const unprivilegedRemoteParticipants = remoteParticipants.filter(
(participant) => !getParticipantIsRoomAdmin(participant)
(participant) => !getParticipantIsRoomAdminOrOwner(participant)
)
const currentSources = useMemo(() => {
@@ -1,12 +0,0 @@
import type { Participant } from 'livekit-client'
export const getParticipantIsRoomAdmin = (
participant: Participant
): boolean => {
const attributes = participant.attributes
if (!attributes) {
return false
}
return attributes?.room_admin === 'true'
}
@@ -0,0 +1,20 @@
import type { Participant } from 'livekit-client'
import { ParticipantRole } from '@/features/rooms/api/ApiRoom'
const participantHasRoomRole = (
participant: Participant,
roles: ParticipantRole[]
): boolean => {
const role = participant.attributes?.room_role
return role !== undefined && roles.includes(role as ParticipantRole)
}
export const getParticipantIsRoomAdmin = (participant: Participant): boolean =>
participantHasRoomRole(participant, ['administrator'])
export const getParticipantIsRoomOwner = (participant: Participant): boolean =>
participantHasRoomRole(participant, ['owner'])
export const getParticipantIsRoomAdminOrOwner = (
participant: Participant
): boolean => participantHasRoomRole(participant, ['administrator', 'owner'])