diff --git a/CHANGELOG.md b/CHANGELOG.md index c5a94848..d9ad274e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to ## [Unreleased] +### Added + +- ✨(any) let any authenticated user manage the lobby on trusted rooms + ### Changed - 📱(frontend) collapse mobile control bar items on narrow viewports diff --git a/src/backend/core/api/permissions.py b/src/backend/core/api/permissions.py index c1fb21b2..3992be0d 100644 --- a/src/backend/core/api/permissions.py +++ b/src/backend/core/api/permissions.py @@ -5,7 +5,7 @@ from django.http import Http404 from rest_framework import permissions -from ..models import RoleChoices +from ..models import RoleChoices, RoomAccessLevel from ..services.participants_management import ( ParticipantNotFoundException, ParticipantsManagement, @@ -198,3 +198,48 @@ class IsPresentInMeeting(permissions.BasePermission): return False except ParticipantsManagementException: return False + + +class CanManageLobby(permissions.BasePermission): + """Grant lobby management (list/accept/deny waiting participants). + + - Room admins/owners can always manage the lobby. + - When the room access level is TRUSTED, any authenticated user who is + currently connected to the meeting can manage the lobby. Presence is + verified cache-first (Redis), falling back to the LiveKit API. + + Access level is always read fresh from the DB; only presence is cached, + so changing the room to RESTRICTED takes effect immediately. + """ + + message = "You are not allowed to manage this room's lobby." + + # pylint: disable=too-many-return-statements + def has_object_permission(self, request, view, obj): # noqa: PLR0911 + """Check privileges first, then the trusted-room presence path.""" + user = request.user + + if not user or not user.is_authenticated: + return False + + # Product choice: lobby management is reserved for session-authenticated + # users with a real account, not holders of a LiveKit room token. + if request.auth and hasattr(request.auth, "video"): + return False + + if obj.is_administrator_or_owner(user): + return True + + if obj.access_level != RoomAccessLevel.TRUSTED: + return False + + self.message = "You must be connected to the meeting to manage its lobby." + + try: + return ParticipantsManagement().check_if_in_meeting_cached( + room_name=str(obj.pk), identity=str(user.sub) + ) + except ParticipantNotFoundException: + return False + except ParticipantsManagementException: + return False diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index bbaa1da4..4dfb43b6 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -536,7 +536,7 @@ class RoomViewSet( methods=["post"], url_path="enter", permission_classes=[ - permissions.HasPrivilegesOnRoom, + permissions.CanManageLobby, ], ) def allow_participant_to_enter(self, request, pk=None): # pylint: disable=unused-argument @@ -574,7 +574,7 @@ class RoomViewSet( methods=["GET"], url_path="waiting-participants", permission_classes=[ - permissions.HasPrivilegesOnRoom, + permissions.CanManageLobby, ], ) def list_waiting_participants(self, request, pk=None): # pylint: disable=unused-argument diff --git a/src/backend/core/services/livekit_events.py b/src/backend/core/services/livekit_events.py index 44835582..939194ff 100644 --- a/src/backend/core/services/livekit_events.py +++ b/src/backend/core/services/livekit_events.py @@ -23,6 +23,7 @@ from core.recording.services.recording_events import ( ) from .lobby import LobbyService +from .presence import PresenceCache from .room_management import ( RoomManagement, RoomManagementException, @@ -99,6 +100,7 @@ class LiveKitEventsService: "egress_ended": self._handle_egress_ended, "room_started": self._handle_room_started, "room_finished": self._handle_room_finished, + "participant_left": self._handle_participant_left, } token_verifier = api.TokenVerifier( @@ -107,6 +109,7 @@ class LiveKitEventsService: ) self.webhook_receiver = api.WebhookReceiver(token_verifier) self.lobby_service = LobbyService() + self.presence_cache = PresenceCache() self.sip_management = SIPManagement() self.recording_events = RecordingEventsService() @@ -285,9 +288,31 @@ class LiveKitEventsService: f"Failed to delete sip dispatch rule for room {room_id}" ) from e + self.presence_cache.clear_room(room_id) + try: self.lobby_service.clear_room_cache(room_id) except Exception as e: raise ActionFailedError( f"Failed to clear room cache for room {room_id}" ) from e + + def _handle_participant_left(self, data): + """Handle 'participant_left': invalidate the presence cache. + + Presence entries are created lazily (only for users who administrate + the lobby of a trusted room), so for most participants this delete is + a no-op DEL on a key that never existed. Eager invalidation shrinks + the window during which a departed participant could still act on a + trusted room's lobby (cache hit until TTL expiry). It is gated behind + `PRESENCE_CLEAR_ON_PARTICIPANT_LEFT` so its production impact can be + measured and the behaviour reverted independently of the feature. + When disabled, invalidation relies on `room_finished` and the TTL. + """ + if not settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT: + return + + identity = data.participant.identity + if not identity: + return + self.presence_cache.clear(data.room.name, identity) diff --git a/src/backend/core/services/participants_management.py b/src/backend/core/services/participants_management.py index 3a77c62b..b53b1ab8 100644 --- a/src/backend/core/services/participants_management.py +++ b/src/backend/core/services/participants_management.py @@ -20,6 +20,7 @@ from livekit.protocol.models import ParticipantInfo from core import utils from .lobby import LobbyService +from .presence import PresenceCache logger = getLogger(__name__) @@ -72,7 +73,9 @@ class ParticipantsManagement: @async_to_sync async def remove(self, room_name: str, identity: str): - """Remove a participant from a room and clear their lobby cache.""" + """Remove a participant from a room and clear their lobby/presence cache.""" + + PresenceCache().clear(room_name, identity) try: LobbyService().clear_participant_cache( @@ -156,6 +159,30 @@ class ParticipantsManagement: finally: await lkapi.aclose() + def check_if_in_meeting_cached(self, room_name: str, identity: str) -> bool: + """Cache-first variant of `check_if_in_meeting`. + + Cache hit -> True without touching LiveKit. + Cache miss -> ask LiveKit; memoize only positive answers. + + Raises the same exceptions as `check_if_in_meeting` so callers keep + failing closed the same way. + """ + if not room_name or not identity: + return False + + presence_cache = PresenceCache() + + if presence_cache.is_marked_present(room_name, identity): + return True + + present = self.check_if_in_meeting(room_name=room_name, identity=identity) + + if present: + presence_cache.mark_present(room_name, identity) + + return present + @async_to_sync async def check_if_in_meeting(self, room_name: str, identity: str) -> bool: """Check whether `identity` is currently a participant in `room_name`. diff --git a/src/backend/core/services/presence.py b/src/backend/core/services/presence.py new file mode 100644 index 00000000..ec1b68d4 --- /dev/null +++ b/src/backend/core/services/presence.py @@ -0,0 +1,50 @@ +"""Presence cache. + +Redis-backed memo of "this identity is currently connected to this room". + +This module is intentionally a *pure cache store* with no dependency on other +services, so that `participants_management` (which talks to LiveKit) can +import it without creating an import cycle. The composition of "check cache, +fall back to LiveKit" lives in +`ParticipantsManagement.check_if_in_meeting_cached`. + +Only positive answers are stored: a sticky negative would lock out someone +who joins right after a miss for the whole TTL. The TTL is a safety net in +case an invalidation webhook is lost. +""" + +from uuid import UUID + +from django.conf import settings +from django.core.cache import cache + + +class PresenceCache: + """Store and invalidate (room, identity) presence entries.""" + + @staticmethod + def _get_cache_key(room_id: UUID | str, identity: str) -> str: + """Cache key for a (room, identity) presence entry.""" + return f"{settings.PRESENCE_KEY_PREFIX}_{room_id!s}_{identity}" + + def is_marked_present(self, room_id: UUID | str, identity: str) -> bool: + """Return True if a positive presence entry exists in cache.""" + return bool(cache.get(self._get_cache_key(room_id, identity))) + + def mark_present(self, room_id: UUID | str, identity: str) -> None: + """Record that `identity` is in `room_id`.""" + cache.set( + self._get_cache_key(room_id, identity), + True, + timeout=settings.PRESENCE_CACHE_TIMEOUT, + ) + + def clear(self, room_id: UUID | str, identity: str) -> None: + """Forget presence for one participant (e.g. on participant_left).""" + cache.delete(self._get_cache_key(room_id, identity)) + + def clear_room(self, room_id: UUID | str) -> None: + """Forget presence for every participant of a room (on room_finished).""" + keys = cache.keys(self._get_cache_key(room_id, "*")) + if keys: + cache.delete_many(keys) diff --git a/src/backend/core/tests/rooms/test_api_rooms_lobby.py b/src/backend/core/tests/rooms/test_api_rooms_lobby.py index 37ca2124..aabca484 100644 --- a/src/backend/core/tests/rooms/test_api_rooms_lobby.py +++ b/src/backend/core/tests/rooms/test_api_rooms_lobby.py @@ -389,7 +389,7 @@ def test_allow_participant_to_enter_anonymous(): def test_allow_participant_to_enter_non_owner(): """Non-privileged users should not be allowed to manage entry requests.""" - room = RoomFactory() + room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) user = UserFactory() client = APIClient() client.force_login(user) @@ -522,7 +522,7 @@ def test_list_waiting_participants_anonymous(): def test_list_waiting_participants_non_owner(): """Non-privileged users should not be allowed to list waiting participants.""" - room = RoomFactory() + room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) user = UserFactory() client = APIClient() client.force_login(user) diff --git a/src/backend/core/tests/rooms/test_api_rooms_lobby_trusted.py b/src/backend/core/tests/rooms/test_api_rooms_lobby_trusted.py new file mode 100644 index 00000000..49f0e64d --- /dev/null +++ b/src/backend/core/tests/rooms/test_api_rooms_lobby_trusted.py @@ -0,0 +1,106 @@ +"""Trusted rooms: any authenticated participant present in the meeting can manage the lobby.""" + +from unittest import mock + +import pytest +from rest_framework.test import APIClient + +from core.factories import RoomFactory, UserFactory +from core.models import RoomAccessLevel +from core.services.presence import PresenceCache + +pytestmark = pytest.mark.django_db + + +@mock.patch( + "core.services.participants_management.ParticipantsManagement.check_if_in_meeting" +) +def test_trusted_room_present_user_can_list_waiting(mock_check): + """Authenticated + present in a trusted room -> 200, LiveKit asked once then cached.""" + mock_check.return_value = True + user = UserFactory() + room = RoomFactory(access_level=RoomAccessLevel.TRUSTED) + client = APIClient() + client.force_login(user) + + url = f"/api/v1.0/rooms/{room.id}/waiting-participants/" + assert client.get(url).status_code == 200 + assert client.get(url).status_code == 200 + assert mock_check.call_count == 1 + + +@mock.patch( + "core.services.participants_management.ParticipantsManagement.check_if_in_meeting" +) +def test_trusted_room_absent_user_forbidden(mock_check): + """Authenticated but not connected to the meeting -> 403.""" + mock_check.return_value = False + user = UserFactory() + room = RoomFactory(access_level=RoomAccessLevel.TRUSTED) + client = APIClient() + client.force_login(user) + + response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/") + assert response.status_code == 403 + + +@mock.patch( + "core.services.participants_management.ParticipantsManagement.check_if_in_meeting" +) +def test_restricted_room_present_user_forbidden(mock_check): + """Presence is not enough on a restricted room; LiveKit must not even be asked.""" + user = UserFactory() + room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) + client = APIClient() + client.force_login(user) + + response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/") + assert response.status_code == 403 + mock_check.assert_not_called() + + +@mock.patch( + "core.services.participants_management.ParticipantsManagement.check_if_in_meeting" +) +def test_trusted_room_presence_cleared_after_leave(mock_check): + """Once the presence cache is cleared (participant_left), LiveKit is re-checked.""" + mock_check.return_value = True + user = UserFactory() + room = RoomFactory(access_level=RoomAccessLevel.TRUSTED) + client = APIClient() + client.force_login(user) + url = f"/api/v1.0/rooms/{room.id}/waiting-participants/" + + assert client.get(url).status_code == 200 + PresenceCache().clear(room.id, str(user.sub)) + + mock_check.return_value = False + assert client.get(url).status_code == 403 + assert mock_check.call_count == 2 + + +def test_trusted_room_anonymous_forbidden(): + """Anonymous users never manage the lobby.""" + room = RoomFactory(access_level=RoomAccessLevel.TRUSTED) + response = APIClient().get(f"/api/v1.0/rooms/{room.id}/waiting-participants/") + assert response.status_code == 401 + + +@mock.patch( + "core.services.participants_management.ParticipantsManagement.check_if_in_meeting" +) +def test_trusted_room_present_user_can_accept_entry(mock_check): + """Authenticated + present in a trusted room can accept a waiting participant.""" + mock_check.return_value = True + user = UserFactory() + room = RoomFactory(access_level=RoomAccessLevel.TRUSTED) + client = APIClient() + client.force_login(user) + + response = client.post( + f"/api/v1.0/rooms/{room.id}/enter/", + {"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True}, + ) + # Permission passed; 404 because that participant isn't actually waiting. + assert response.status_code == 404 + assert response.json() == {"message": "Participant not found."} diff --git a/src/backend/core/tests/services/test_livekit_events.py b/src/backend/core/tests/services/test_livekit_events.py index c9b30f86..548c71c5 100644 --- a/src/backend/core/tests/services/test_livekit_events.py +++ b/src/backend/core/tests/services/test_livekit_events.py @@ -854,3 +854,31 @@ def test_receive_ignores_connection_test_room( mock_handle_room_started.assert_not_called() mock_handle_room_finished.assert_not_called() + + +@mock.patch("core.services.presence.cache.delete") +def test_participant_left_clearing_gated_by_setting(mock_delete, service, settings): + """PRESENCE_CLEAR_ON_PARTICIPANT_LEFT toggles eager presence invalidation.""" + data = mock.Mock() + data.room.name = "room-name" + data.participant.identity = "user-sub" + + settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT = False + service._handle_participant_left(data) # pylint: disable=protected-access + mock_delete.assert_not_called() + + settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT = True + service._handle_participant_left(data) # pylint: disable=protected-access + mock_delete.assert_called_once() + + +@mock.patch("core.services.presence.cache.delete") +def test_participant_left_without_identity_is_ignored(mock_delete, service, settings): + """No cache operation when the webhook carries no identity.""" + settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT = True + data = mock.Mock() + data.room.name = "room-name" + data.participant.identity = "" + + service._handle_participant_left(data) # pylint: disable=protected-access + mock_delete.assert_not_called() diff --git a/src/backend/core/tests/services/test_presence.py b/src/backend/core/tests/services/test_presence.py new file mode 100644 index 00000000..7dc08cdb --- /dev/null +++ b/src/backend/core/tests/services/test_presence.py @@ -0,0 +1,90 @@ +"""Tests for the presence cache and the cached presence check.""" + +# pylint: disable=W0212 + +from unittest import mock +from uuid import uuid4 + +from django.core.cache import cache + +import pytest + +from core.services.participants_management import ( + ParticipantNotFoundException, + ParticipantsManagement, + ParticipantsManagementException, +) +from core.services.presence import PresenceCache + + +@mock.patch( + "core.services.participants_management.ParticipantsManagement.check_if_in_meeting" +) +def test_presence_cache_hit_skips_livekit(mock_check): + """A cached positive answer must not call LiveKit.""" + room_id, identity = str(uuid4()), "user-sub" + PresenceCache().mark_present(room_id, identity) + + assert ( + ParticipantsManagement().check_if_in_meeting_cached(room_id, identity) is True + ) + mock_check.assert_not_called() + + +@mock.patch( + "core.services.participants_management.ParticipantsManagement.check_if_in_meeting" +) +def test_presence_cache_miss_calls_livekit_and_caches_positive(mock_check): + """On a miss, LiveKit is asked once and a positive answer is memoized.""" + mock_check.return_value = True + room_id, identity = str(uuid4()), "user-sub" + service = ParticipantsManagement() + + assert service.check_if_in_meeting_cached(room_id, identity) is True + assert service.check_if_in_meeting_cached(room_id, identity) is True + assert mock_check.call_count == 1 + + +@mock.patch( + "core.services.participants_management.ParticipantsManagement.check_if_in_meeting" +) +def test_presence_negative_not_cached(mock_check): + """Negative answers are never memoized.""" + mock_check.return_value = False + room_id, identity = str(uuid4()), "user-sub" + service = ParticipantsManagement() + + assert service.check_if_in_meeting_cached(room_id, identity) is False + assert service.check_if_in_meeting_cached(room_id, identity) is False + assert mock_check.call_count == 2 + + +@mock.patch( + "core.services.participants_management.ParticipantsManagement.check_if_in_meeting" +) +def test_presence_errors_propagate_and_cache_nothing(mock_check): + """LiveKit errors propagate to the caller (which fails closed); nothing cached.""" + room_id, identity = str(uuid4()), "user-sub" + + for exc in (ParticipantNotFoundException(), ParticipantsManagementException()): + mock_check.side_effect = exc + with pytest.raises(type(exc)): + ParticipantsManagement().check_if_in_meeting_cached(room_id, identity) + assert cache.get(PresenceCache._get_cache_key(room_id, identity)) is None + + +def test_presence_clear_and_clear_room(): + """clear() removes one entry, clear_room() removes all entries of a room.""" + room_id, other_room = str(uuid4()), str(uuid4()) + presence = PresenceCache() + presence.mark_present(room_id, "a") + presence.mark_present(room_id, "b") + presence.mark_present(other_room, "a") + + presence.clear(room_id, "a") + assert presence.is_marked_present(room_id, "a") is False + assert presence.is_marked_present(room_id, "b") is True + + presence.clear_room(room_id) + assert presence.is_marked_present(room_id, "b") is False + assert presence.is_marked_present(other_room, "a") is True diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index ddcaa402..d6f1e17c 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -856,6 +856,15 @@ class Base(Configuration): ) # Lobby configurations + PRESENCE_KEY_PREFIX = values.Value( + "room_presence", environ_name="PRESENCE_KEY_PREFIX", environ_prefix=None + ) + PRESENCE_CACHE_TIMEOUT = values.PositiveIntegerValue( + 3600, environ_name="PRESENCE_CACHE_TIMEOUT", environ_prefix=None + ) + PRESENCE_CLEAR_ON_PARTICIPANT_LEFT = values.BooleanValue( + True, environ_name="PRESENCE_CLEAR_ON_PARTICIPANT_LEFT", environ_prefix=None + ) LOBBY_KEY_PREFIX = values.Value( "room_lobby", environ_name="LOBBY_KEY_PREFIX", environ_prefix=None )