Compare commits

...

1 Commits

Author SHA1 Message Date
lebaudantoine 387269d1d2 wip allow setting encryption on a room 2026-09-25 00:39:28 +02:00
18 changed files with 413 additions and 22 deletions
+3
View File
@@ -76,6 +76,9 @@ def get_frontend_configuration(request):
"authenticated_users_can_edit_display_name": ( "authenticated_users_can_edit_display_name": (
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
), ),
"encryption": {
"is_enabled": settings.ENCRYPTION_ENABLED,
},
} }
frontend_configuration.update(settings.FRONTEND_CONFIGURATION) frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
return Response(frontend_configuration) return Response(frontend_configuration)
+47 -1
View File
@@ -38,6 +38,7 @@ class UserSerializer(serializers.ModelSerializer):
"short_name", "short_name",
"timezone", "timezone",
"language", "language",
"default_encryption_mode",
"default_room_access_level", "default_room_access_level",
"default_room_configuration", "default_room_configuration",
] ]
@@ -53,6 +54,14 @@ class UserSerializer(serializers.ModelSerializer):
raise serializers.ValidationError(e.errors()) from e raise serializers.ValidationError(e.errors()) from e
return value return value
def validate_default_encryption_mode(self, value):
"""Reject a non-none default when the server has encryption disabled."""
if value != models.EncryptionMode.NONE and not settings.ENCRYPTION_ENABLED:
raise serializers.ValidationError(
_("End-to-end encryption is disabled on this server.")
)
return value
class UserLightSerializer(serializers.ModelSerializer): class UserLightSerializer(serializers.ModelSerializer):
"""Serialize users with limited fields.""" """Serialize users with limited fields."""
@@ -94,6 +103,7 @@ class ResourceAccessSerializerMixin:
raise PermissionDenied( raise PermissionDenied(
"Only owners of a room can assign other users as owners." "Only owners of a room can assign other users as owners."
) )
return data return data
def validate_resource(self, resource): def validate_resource(self, resource):
@@ -148,7 +158,15 @@ class RoomSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = models.Room model = models.Room
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code"] fields = [
"id",
"name",
"slug",
"configuration",
"access_level",
"pin_code",
"encryption_mode",
]
read_only_fields = ["id", "slug", "pin_code"] read_only_fields = ["id", "slug", "pin_code"]
def validate_configuration(self, value): def validate_configuration(self, value):
@@ -161,6 +179,32 @@ class RoomSerializer(serializers.ModelSerializer):
raise serializers.ValidationError(e.errors()) from e raise serializers.ValidationError(e.errors()) from e
return value return value
def validate_encryption_mode(self, value):
"""Encryption mode is part of the link's semantics (the passphrase
lives in the URL hash for `basic` rooms) so it cannot be changed once
the room exists."""
instance = self.instance
if instance and instance.encryption_mode != value:
raise serializers.ValidationError(
"Encryption mode cannot be changed after room creation."
)
return value
def validate_access_level(self, value):
"""Encrypted rooms must stay restricted — the lobby is the only way
to enforce per-participant admission, and basic encryption relies on
the host vetting each joiner before they receive the in-URL key."""
instance = self.instance
if (
instance
and instance.encryption_mode != models.EncryptionMode.NONE
and value != models.RoomAccessLevel.RESTRICTED
):
raise serializers.ValidationError(
"Encrypted rooms require restricted access level."
)
return value
def to_representation(self, instance): def to_representation(self, instance):
""" """
Add users only for administrator users. Add users only for administrator users.
@@ -197,12 +241,14 @@ class RoomSerializer(serializers.ModelSerializer):
if should_access_room: if should_access_room:
room_id = f"{instance.id!s}" room_id = f"{instance.id!s}"
username = request.query_params.get("username", None) username = request.query_params.get("username", None)
output["livekit"] = utils.generate_livekit_config( output["livekit"] = utils.generate_livekit_config(
room_id=room_id, room_id=room_id,
user=request.user, user=request.user,
username=username, username=username,
configuration=output["configuration"], configuration=output["configuration"],
role=role, role=role,
encryption_mode=instance.encryption_mode,
) )
else: else:
del output["pin_code"] del output["pin_code"]
+24 -2
View File
@@ -249,6 +249,18 @@ class RoomViewSet(
Apply the user's default room preferences (access level and configuration) Apply the user's default room preferences (access level and configuration)
unless the request explicitly provides its own values. unless the request explicitly provides its own values.
""" """
encryption_mode = serializer.validated_data.get(
"encryption_mode", models.EncryptionMode.NONE
)
if (
encryption_mode != models.EncryptionMode.NONE
and not settings.ENCRYPTION_ENABLED
):
raise drf_exceptions.ValidationError(
{"encryption_mode": "Encryption is not enabled on this server."}
)
user = self.request.user user = self.request.user
save_kwargs = {} save_kwargs = {}
@@ -313,16 +325,21 @@ class RoomViewSet(
"""Start recording a room.""" """Start recording a room."""
serializer = serializers.StartRecordingSerializer(data=request.data) serializer = serializers.StartRecordingSerializer(data=request.data)
if not serializer.is_valid(): if not serializer.is_valid():
return drf_response.Response( return drf_response.Response(
{"detail": "Invalid request."}, status=drf_status.HTTP_400_BAD_REQUEST {"detail": "Invalid request."},
status=drf_status.HTTP_400_BAD_REQUEST,
) )
mode = serializer.validated_data["mode"] mode = serializer.validated_data["mode"]
options = serializer.validated_data.get("options") options = serializer.validated_data.get("options")
room = self.get_object() room = self.get_object()
if room.is_encrypted:
raise drf_exceptions.ValidationError(
{"detail": "Recording is unavailable in encrypted rooms."}
)
try: try:
with transaction.atomic(): with transaction.atomic():
recording = models.Recording.objects.create( recording = models.Recording.objects.create(
@@ -645,6 +662,11 @@ class RoomViewSet(
room = self.get_object() room = self.get_object()
if room.is_encrypted:
raise drf_exceptions.ValidationError(
{"detail": "Subtitles are unavailable in encrypted rooms."}
)
try: try:
SubtitleService().start_subtitle(room) SubtitleService().start_subtitle(room)
except SubtitleException: except SubtitleException:
+12
View File
@@ -5,6 +5,7 @@ Core application enums declaration
import re import re
from django.conf import global_settings, settings from django.conf import global_settings, settings
from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
UUID_REGEX = ( UUID_REGEX = (
@@ -32,3 +33,14 @@ ALL_LANGUAGES = getattr(
"ALL_LANGUAGES", "ALL_LANGUAGES",
[(language, _(name)) for language, name in global_settings.LANGUAGES], [(language, _(name)) for language, name in global_settings.LANGUAGES],
) )
class EncryptionMode(models.TextChoices):
"""Encryption mode for a room.
Kept as an enum (not a boolean) so future modes — e.g. a vault-managed
per-user key flow — can be added without another schema migration.
"""
NONE = "none", _("No encryption")
BASIC = "basic", _("Passphrase-in-URL encryption")
@@ -0,0 +1,46 @@
"""Add Room.encryption_mode and User.default_encryption_mode (enum-based).
We store the mode as an enum (CharField with choices) rather than a boolean
so a future "advanced" mode (per-user vault keys, etc.) can be added without
a schema migration.
"""
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0023_alter_recording_status"),
]
operations = [
migrations.AddField(
model_name="room",
name="encryption_mode",
field=models.CharField(
choices=[
("none", "No encryption"),
("basic", "Passphrase-in-URL encryption"),
],
default="none",
help_text="End-to-end encryption mode for this room.",
max_length=20,
verbose_name="Encryption mode",
),
),
migrations.AddField(
model_name="user",
name="default_encryption_mode",
field=models.CharField(
choices=[
("none", "No encryption"),
("basic", "Passphrase-in-URL encryption"),
],
default="none",
help_text="Encryption mode pre-selected when this user creates a new meeting.",
max_length=20,
verbose_name="Default encryption mode",
),
),
]
+73 -3
View File
@@ -26,6 +26,7 @@ from lasuite.tools.email import get_domain_from_email
from timezone_field import TimeZoneField from timezone_field import TimeZoneField
from . import fields, utils from . import fields, utils
from .enums import EncryptionMode
from .recording.enums import FileExtension from .recording.enums import FileExtension
from .validators import sub_validator from .validators import sub_validator
@@ -216,6 +217,15 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"Unselect this instead of deleting accounts." "Unselect this instead of deleting accounts."
), ),
) )
default_encryption_mode = models.CharField(
_("Default encryption mode"),
max_length=20,
choices=EncryptionMode.choices,
default=EncryptionMode.NONE,
help_text=_(
"Encryption mode pre-selected when this user creates a new meeting."
),
)
objects = auth_models.UserManager() objects = auth_models.UserManager()
@@ -411,6 +421,13 @@ class Room(Resource):
choices=RoomAccessLevel.choices, choices=RoomAccessLevel.choices,
default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL, default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
) )
encryption_mode = models.CharField(
max_length=20,
choices=EncryptionMode.choices,
default=EncryptionMode.NONE,
verbose_name=_("Encryption mode"),
help_text=_("End-to-end encryption mode for this room."),
)
# Public configuration exposed to any room participant via the API # Public configuration exposed to any room participant via the API
configuration = models.JSONField( configuration = models.JSONField(
blank=True, blank=True,
@@ -437,20 +454,68 @@ class Room(Resource):
return capfirst(self.name) return capfirst(self.name)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""Generate a unique n-digit pin code for new rooms.""" """Restrict new encrypted rooms and allocate PINs for unencrypted rooms.
# Roomkit devices also join by PIN, so a PIN is needed as soon as Skip PIN allocation for encrypted rooms — the SIP gateway will
# either integration is enabled. always reject calls to them (no way to derive the key), and the
PIN namespace is finite (10**length): no point burning slots that
can never be dialed.
Also run `clean()` so the encryption invariants are enforced on
every save path (ORM, admin, shell), not only via the DRF
serializer.
"""
# Override both explicit access levels and user defaults on creation.
# Updates remain subject to clean() instead of being silently normalized.
if self._state.adding and self.is_encrypted:
self.access_level = RoomAccessLevel.RESTRICTED
self.clean()
if ( if (
(settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED) (settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED)
and not self.pk and not self.pk
and not self.pin_code and not self.pin_code
and self.encryption_mode == EncryptionMode.NONE
): ):
self.pin_code = self.generate_unique_pin_code( self.pin_code = self.generate_unique_pin_code(
length=settings.ROOM_TELEPHONY_PIN_LENGTH length=settings.ROOM_TELEPHONY_PIN_LENGTH
) )
super().save(*args, **kwargs) super().save(*args, **kwargs)
def clean(self):
"""Enforce encryption-mode invariants outside DRF.
Two rules:
- `encryption_mode` is set at creation and never mutated afterwards
(the URL-hash passphrase encodes assumptions about it).
- An encrypted room must be at the RESTRICTED access level so the
host vets joiners before they ever see the in-URL key.
"""
super().clean()
if self.pk is not None:
previous = Room.objects.filter(pk=self.pk).only("encryption_mode").first()
if (
previous is not None
and previous.encryption_mode != self.encryption_mode
):
raise ValidationError(
{
"encryption_mode": _(
"Encryption mode cannot be changed after room creation."
)
}
)
if (
self.encryption_mode != EncryptionMode.NONE
and self.access_level != RoomAccessLevel.RESTRICTED
):
raise ValidationError(
{
"access_level": _(
"Encrypted rooms must use the 'restricted' access level."
)
}
)
def clean_fields(self, exclude=None): def clean_fields(self, exclude=None):
""" """
Automatically generate the slug from the name and make sure it does not look like a UUID. Automatically generate the slug from the name and make sure it does not look like a UUID.
@@ -473,6 +538,11 @@ class Room(Resource):
"""Check if a room is public""" """Check if a room is public"""
return self.access_level == RoomAccessLevel.PUBLIC return self.access_level == RoomAccessLevel.PUBLIC
@property
def is_encrypted(self):
"""Convenience: any non-none encryption mode counts as encrypted."""
return self.encryption_mode != EncryptionMode.NONE
@staticmethod @staticmethod
def generate_unique_pin_code(length): def generate_unique_pin_code(length):
"""Generate a unique n-digit PIN code""" """Generate a unique n-digit PIN code"""
+3 -1
View File
@@ -275,7 +275,9 @@ class LiveKitEventsService:
except models.Room.DoesNotExist as err: except models.Room.DoesNotExist as err:
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED: if (
settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED
) and not room.is_encrypted:
try: try:
self.sip_management.ensure_dispatch_rule(room) self.sip_management.ensure_dispatch_rule(room)
except SIPException as e: except SIPException as e:
+22 -5
View File
@@ -48,8 +48,11 @@ class LobbyParticipant:
color: str color: str
id: str id: str
entered_at: str entered_at: str
# Whether the user signed in (e.g. via ProConnect). Surfaced to admins so
# they can decide whether to accept self-declared identities.
is_authenticated: bool = False
def to_dict(self) -> Dict[str, str]: def to_dict(self) -> Dict[str, object]:
"""Serialize the participant object to a dict representation.""" """Serialize the participant object to a dict representation."""
return { return {
"status": self.status.value, "status": self.status.value,
@@ -57,6 +60,7 @@ class LobbyParticipant:
"id": self.id, "id": self.id,
"color": self.color, "color": self.color,
"entered_at": self.entered_at, "entered_at": self.entered_at,
"is_authenticated": self.is_authenticated,
} }
@classmethod @classmethod
@@ -72,6 +76,7 @@ class LobbyParticipant:
id=data["id"], id=data["id"],
color=data["color"], color=data["color"],
entered_at=data["entered_at"], entered_at=data["entered_at"],
is_authenticated=bool(data.get("is_authenticated", False)),
) )
except (KeyError, ValueError) as e: except (KeyError, ValueError) as e:
logger.exception("Error creating Participant from dict:") logger.exception("Error creating Participant from dict:")
@@ -144,7 +149,7 @@ class LobbyService:
key=settings.LOBBY_COOKIE_NAME, key=settings.LOBBY_COOKIE_NAME,
value=participant_id, value=participant_id,
httponly=True, httponly=True,
secure=True, secure=not settings.DEBUG,
samesite="Lax", samesite="Lax",
) )
@@ -208,6 +213,7 @@ class LobbyService:
id=participant_id, id=participant_id,
color=utils.generate_color(participant_id), color=utils.generate_color(participant_id),
entered_at=timezone.now().isoformat(), entered_at=timezone.now().isoformat(),
is_authenticated=request.user.is_authenticated,
) )
else: else:
participant.status = LobbyParticipantStatus.ACCEPTED participant.status = LobbyParticipantStatus.ACCEPTED
@@ -220,19 +226,24 @@ class LobbyService:
configuration=room.configuration, configuration=room.configuration,
participant_id=participant_id, participant_id=participant_id,
role=user_role, role=user_role,
encryption_mode=room.encryption_mode,
) )
return participant, livekit_config return participant, livekit_config
livekit_config = None livekit_config = None
if participant is None: if participant is None:
participant = self.enter(room.id, participant_id, username) participant = self.enter(
room.id,
participant_id,
username,
is_authenticated=request.user.is_authenticated,
)
elif participant.status == LobbyParticipantStatus.WAITING: elif participant.status == LobbyParticipantStatus.WAITING:
self.refresh_waiting_status(room.id, participant_id) self.refresh_waiting_status(room.id, participant_id)
elif participant.status == LobbyParticipantStatus.ACCEPTED: elif participant.status == LobbyParticipantStatus.ACCEPTED:
# wrongly named, contains access token to join a room
livekit_config = utils.generate_livekit_config( livekit_config = utils.generate_livekit_config(
room_id=room_id, room_id=room_id,
user=request.user, user=request.user,
@@ -241,6 +252,7 @@ class LobbyService:
configuration=room.configuration, configuration=room.configuration,
participant_id=participant_id, participant_id=participant_id,
role=user_role, role=user_role,
encryption_mode=room.encryption_mode,
) )
return participant, livekit_config return participant, livekit_config
@@ -258,7 +270,11 @@ class LobbyService:
self._index_touch(room_id) self._index_touch(room_id)
def enter( def enter(
self, room_id: UUID, participant_id: str, username: str self,
room_id: UUID,
participant_id: str,
username: str,
is_authenticated: bool = False,
) -> LobbyParticipant: ) -> LobbyParticipant:
"""Add participant to waiting lobby.""" """Add participant to waiting lobby."""
@@ -270,6 +286,7 @@ class LobbyService:
id=participant_id, id=participant_id,
color=color, color=color,
entered_at=timezone.now().isoformat(), entered_at=timezone.now().isoformat(),
is_authenticated=is_authenticated,
) )
try: try:
@@ -312,3 +312,34 @@ def test_api_rooms_create_authenticated_blank_user_default_access_level():
assert response.status_code == 201 assert response.status_code == 201
room = Room.objects.get() room = Room.objects.get()
assert room.access_level == settings.RESOURCE_DEFAULT_ACCESS_LEVEL assert room.access_level == settings.RESOURCE_DEFAULT_ACCESS_LEVEL
@pytest.mark.parametrize("encryption_mode", ["none", "basic"])
@pytest.mark.parametrize(
"user_default", [None, RoomAccessLevel.PUBLIC, RoomAccessLevel.TRUSTED]
)
@pytest.mark.parametrize(
"requested_access", [None, RoomAccessLevel.PUBLIC, RoomAccessLevel.TRUSTED]
)
def test_api_rooms_create_encryption_access_precedence(
settings, encryption_mode, user_default, requested_access
):
"""Encryption overrides request and user access defaults only for encrypted rooms."""
settings.ENCRYPTION_ENABLED = True
user = UserFactory(default_room_access_level=user_default)
client = APIClient()
client.force_login(user)
data = {"name": "New room", "encryption_mode": encryption_mode}
if requested_access is not None:
data["access_level"] = requested_access
response = client.post("/api/v1.0/rooms/", data)
assert response.status_code == 201
expected_access = (
RoomAccessLevel.RESTRICTED
if encryption_mode == "basic"
else requested_access or user_default or settings.RESOURCE_DEFAULT_ACCESS_LEVEL
)
assert response.json()["access_level"] == expected_access
assert Room.objects.get().access_level == expected_access
@@ -62,6 +62,7 @@ def test_request_entry_anonymous(settings):
"status": "waiting", "status": "waiting",
"color": "mocked-color", "color": "mocked-color",
"entered_at": "2025-01-01T10:00:00+00:00", "entered_at": "2025-01-01T10:00:00+00:00",
"is_authenticated": False,
"livekit": None, "livekit": None,
} }
@@ -75,9 +76,12 @@ def test_request_entry_anonymous(settings):
@freeze_time("2025-01-01 10:00:00") @freeze_time("2025-01-01 10:00:00")
def test_request_entry_authenticated_user(settings): @pytest.mark.parametrize("encryption_mode", ["none", "basic"])
def test_request_entry_authenticated_user(settings, encryption_mode):
"""Authenticated users should be allowed to request entry.""" """Authenticated users should be allowed to request entry."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(
access_level=RoomAccessLevel.RESTRICTED, encryption_mode=encryption_mode
)
user = UserFactory() user = UserFactory()
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
@@ -113,6 +117,7 @@ def test_request_entry_authenticated_user(settings):
"status": "waiting", "status": "waiting",
"color": "mocked-color", "color": "mocked-color",
"entered_at": "2025-01-01T10:00:00+00:00", "entered_at": "2025-01-01T10:00:00+00:00",
"is_authenticated": True,
"livekit": None, "livekit": None,
} }
@@ -189,6 +194,7 @@ def test_request_entry_with_existing_participants(settings):
"entered_at": "2025-01-01T10:00:00+00:00", "entered_at": "2025-01-01T10:00:00+00:00",
"status": "waiting", "status": "waiting",
"color": "mocked-color", "color": "mocked-color",
"is_authenticated": False,
"livekit": None, "livekit": None,
} }
@@ -243,6 +249,7 @@ def test_request_entry_public_room(settings):
"entered_at": "2025-01-01T10:00:00+00:00", "entered_at": "2025-01-01T10:00:00+00:00",
"status": "accepted", "status": "accepted",
"color": "mocked-color", "color": "mocked-color",
"is_authenticated": False,
"livekit": {"token": "test-token"}, "livekit": {"token": "test-token"},
} }
@@ -297,6 +304,7 @@ def test_request_entry_authenticated_user_public_room(settings):
"entered_at": "2025-01-01T10:00:00+00:00", "entered_at": "2025-01-01T10:00:00+00:00",
"status": "accepted", "status": "accepted",
"color": "mocked-color", "color": "mocked-color",
"is_authenticated": True,
"livekit": {"token": "test-token"}, "livekit": {"token": "test-token"},
} }
@@ -354,6 +362,7 @@ def test_request_entry_waiting_participant_public_room(settings):
"status": "accepted", "status": "accepted",
"color": "#123456", "color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00", "entered_at": "2025-01-01T10:00:00+00:00",
"is_authenticated": False,
"livekit": {"token": "test-token"}, "livekit": {"token": "test-token"},
} }
@@ -623,6 +632,7 @@ def test_list_waiting_participants_success(settings):
"username": "user2", "username": "user2",
"status": "waiting", "status": "waiting",
"color": "#654321", "color": "#654321",
"is_authenticated": False,
"entered_at": "2025-01-01T10:05:00+00:00", "entered_at": "2025-01-01T10:05:00+00:00",
}, },
{ {
@@ -630,6 +640,7 @@ def test_list_waiting_participants_success(settings):
"username": "user1", "username": "user1",
"status": "waiting", "status": "waiting",
"color": "#123456", "color": "#123456",
"is_authenticated": False,
"entered_at": "2025-01-01T10:00:00+00:00", "entered_at": "2025-01-01T10:00:00+00:00",
}, },
] ]
@@ -33,6 +33,7 @@ def test_api_rooms_retrieve_anonymous_private_pk():
"id": str(room.id), "id": str(room.id),
"name": room.name, "name": room.name,
"slug": room.slug, "slug": room.slug,
"encryption_mode": room.encryption_mode,
} }
@@ -52,6 +53,7 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
"id": str(room.id), "id": str(room.id),
"name": room.name, "name": room.name,
"slug": room.slug, "slug": room.slug,
"encryption_mode": room.encryption_mode,
} }
@@ -70,6 +72,7 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
"id": str(room.id), "id": str(room.id),
"name": room.name, "name": room.name,
"slug": room.slug, "slug": room.slug,
"encryption_mode": room.encryption_mode,
} }
@@ -86,6 +89,7 @@ def test_api_rooms_retrieve_anonymous_private_slug():
"id": str(room.id), "id": str(room.id),
"name": room.name, "name": room.name,
"slug": room.slug, "slug": room.slug,
"encryption_mode": room.encryption_mode,
} }
@@ -102,6 +106,7 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
"id": str(room.id), "id": str(room.id),
"name": room.name, "name": room.name,
"slug": room.slug, "slug": room.slug,
"encryption_mode": room.encryption_mode,
} }
@@ -217,6 +222,7 @@ def test_api_rooms_retrieve_anonymous_public(mock_token):
"name": room.name, "name": room.name,
"pin_code": room.pin_code, "pin_code": room.pin_code,
"slug": room.slug, "slug": room.slug,
"encryption_mode": room.encryption_mode,
} }
mock_token.assert_called_once() mock_token.assert_called_once()
@@ -263,6 +269,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
"name": room.name, "name": room.name,
"pin_code": room.pin_code, "pin_code": room.pin_code,
"slug": room.slug, "slug": room.slug,
"encryption_mode": room.encryption_mode,
} }
mock_token.assert_called_once_with( mock_token.assert_called_once_with(
@@ -273,6 +280,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
sources=["camera"], sources=["camera"],
role=None, role=None,
participant_id=None, participant_id=None,
encryption_mode="none",
) )
@@ -314,6 +322,7 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
"name": room.name, "name": room.name,
"pin_code": room.pin_code, "pin_code": room.pin_code,
"slug": room.slug, "slug": room.slug,
"encryption_mode": room.encryption_mode,
} }
mock_token.assert_called_once_with( mock_token.assert_called_once_with(
@@ -324,6 +333,7 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
sources=None, sources=None,
role=None, role=None,
participant_id=None, participant_id=None,
encryption_mode="none",
) )
@@ -349,6 +359,7 @@ def test_api_rooms_retrieve_authenticated():
"id": str(room.id), "id": str(room.id),
"name": room.name, "name": room.name,
"slug": room.slug, "slug": room.slug,
"encryption_mode": room.encryption_mode,
} }
@@ -400,6 +411,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
"name": room.name, "name": room.name,
"pin_code": room.pin_code, "pin_code": room.pin_code,
"slug": room.slug, "slug": room.slug,
"encryption_mode": room.encryption_mode,
} }
mock_token.assert_called_once_with( mock_token.assert_called_once_with(
@@ -410,6 +422,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
sources=["camera"], sources=["camera"],
role=str(RoleChoices.MEMBER), role=str(RoleChoices.MEMBER),
participant_id=None, participant_id=None,
encryption_mode="none",
) )
@@ -461,6 +474,7 @@ def test_api_rooms_retrieve_administrators(
"short_name": other_user_access.user.short_name, "short_name": other_user_access.user.short_name,
"timezone": "UTC", "timezone": "UTC",
"language": other_user_access.user.language, "language": other_user_access.user.language,
"default_encryption_mode": "none",
}, },
"resource": str(room.id), "resource": str(room.id),
"role": other_user_access.role, "role": other_user_access.role,
@@ -476,6 +490,7 @@ def test_api_rooms_retrieve_administrators(
"short_name": user_access.user.short_name, "short_name": user_access.user.short_name,
"timezone": "UTC", "timezone": "UTC",
"language": user_access.user.language, "language": user_access.user.language,
"default_encryption_mode": "none",
}, },
"resource": str(room.id), "resource": str(room.id),
"role": user_access.role, "role": user_access.role,
@@ -496,6 +511,7 @@ def test_api_rooms_retrieve_administrators(
"name": room.name, "name": room.name,
"pin_code": room.pin_code, "pin_code": room.pin_code,
"slug": room.slug, "slug": room.slug,
"encryption_mode": room.encryption_mode,
} }
mock_token.assert_called_once_with( mock_token.assert_called_once_with(
@@ -506,4 +522,25 @@ def test_api_rooms_retrieve_administrators(
sources=None, sources=None,
role=str(user_access.role), role=str(user_access.role),
participant_id=None, participant_id=None,
encryption_mode="none",
) )
@pytest.mark.parametrize("encryption_mode", ["none", "basic"])
@mock.patch("core.utils.generate_token", return_value="test-token")
def test_api_rooms_retrieve_custom_username(mock_token, encryption_mode, settings):
"""Encryption does not override the participant's requested display name."""
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = True
user = UserFactory(full_name="Profile Name")
room = RoomFactory(
access_level=RoomAccessLevel.RESTRICTED, encryption_mode=encryption_mode
)
UserResourceAccessFactory(resource=room, user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/rooms/{room.id}/", {"username": "Custom Name"})
assert response.status_code == 200
assert mock_token.call_args.kwargs["username"] == "Custom Name"
assert mock_token.call_args.kwargs["encryption_mode"] == encryption_mode
@@ -410,3 +410,24 @@ def test_api_rooms_update_livekit_sync_failure(mock_update_metadata, exception):
"configuration": {"can_publish_sources": ["camera"]}, "configuration": {"can_publish_sources": ["camera"]},
}, },
) )
@pytest.mark.parametrize(
"access_level", [RoomAccessLevel.PUBLIC, RoomAccessLevel.TRUSTED]
)
def test_api_rooms_update_encrypted_access_rejected(access_level):
"""API updates cannot change an encrypted room away from restricted access."""
room = RoomFactory(encryption_mode="basic")
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/rooms/{room.id}/", {"access_level": access_level}
)
assert response.status_code == 400
assert "access_level" in response.json()
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.RESTRICTED
+11 -1
View File
@@ -303,6 +303,7 @@ def test_request_entry_public_room(
configuration=room.configuration, configuration=room.configuration,
participant_id="test-participant-id", participant_id="test-participant-id",
role=None, role=None,
encryption_mode="none",
) )
lobby_service._get_participant.assert_called_once_with(room.id, participant_id) lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -342,6 +343,7 @@ def test_request_entry_trusted_room(
configuration=room.configuration, configuration=room.configuration,
participant_id="test-participant-id", participant_id="test-participant-id",
role=None, role=None,
encryption_mode="none",
) )
lobby_service._get_participant.assert_called_once_with(room.id, participant_id) lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -374,7 +376,12 @@ def test_request_entry_new_participant(
assert participant == participant_data assert participant == participant_data
assert livekit_config is None assert livekit_config is None
mock_enter.assert_called_once_with(room.id, participant_id, username) mock_enter.assert_called_once_with(
room.id,
participant_id,
username,
is_authenticated=request.user.is_authenticated,
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id) lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -442,6 +449,7 @@ def test_request_entry_accepted_participant(
configuration=room.configuration, configuration=room.configuration,
participant_id="test-participant-id", participant_id="test-participant-id",
role=None, role=None,
encryption_mode="none",
) )
lobby_service._get_participant.assert_called_once_with(room.id, participant_id) lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -483,6 +491,7 @@ def test_request_entry_participant_with_role(
configuration=room.configuration, configuration=room.configuration,
participant_id="test-participant-id", participant_id="test-participant-id",
role="administrator", role="administrator",
encryption_mode="none",
) )
lobby_service._get_participant.assert_called_once_with(room.id, participant_id) lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -886,6 +895,7 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
"id": participant_id, "id": participant_id,
"color": "#123456", "color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00", "entered_at": "2025-01-01T10:00:00+00:00",
"is_authenticated": False,
} }
mock_cache.set.assert_called_once_with( mock_cache.set.assert_called_once_with(
"mocked_cache_key", expected_data, timeout=60 "mocked_cache_key", expected_data, timeout=60
+1
View File
@@ -127,6 +127,7 @@ def test_api_users_retrieve_me_authenticated(settings):
"short_name": user.short_name, "short_name": user.short_name,
"language": user.language, "language": user.language,
"timezone": "UTC", "timezone": "UTC",
"default_encryption_mode": "none",
} }
@@ -360,3 +360,35 @@ def test_pin_generation_upper_bound(mock_randbelow, settings):
# Assert called with the right exclusive upper bound, 10^5 # Assert called with the right exclusive upper bound, 10^5
mock_randbelow.assert_called_with(100000) mock_randbelow.assert_called_with(100000)
@pytest.mark.parametrize("access_level", [None, *RoomAccessLevel.values])
@pytest.mark.parametrize("use_manager", [False, True])
def test_models_encrypted_room_creation_is_restricted(access_level, use_manager):
"""Both save and manager creation normalize encrypted rooms before validation."""
fields = {"name": "Encrypted room", "encryption_mode": "basic"}
if access_level is not None:
fields["access_level"] = access_level
if use_manager:
room = Room.objects.create(**fields)
else:
room = Room(**fields)
# A caller may assign the primary key before the first save.
room.pk = room.id
room.save()
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.RESTRICTED
@pytest.mark.parametrize(
"access_level", [RoomAccessLevel.PUBLIC, RoomAccessLevel.TRUSTED]
)
def test_models_encrypted_room_access_update_rejected(access_level):
"""Existing encrypted rooms reject incompatible access instead of normalizing it."""
room = Room.objects.create(name="Encrypted room", encryption_mode="basic")
room.access_level = access_level
with pytest.raises(ValidationError) as excinfo:
room.save()
assert "access_level" in excinfo.value.message_dict
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.RESTRICTED
+18 -4
View File
@@ -57,21 +57,35 @@ def test_generate_token_authenticated_fallback_user_representation():
assert claims["name"] == str(user) assert claims["name"] == str(user)
def test_generate_token_explicit_username_overrides_default(): @pytest.mark.parametrize("encryption_mode", ["none", "basic"])
def test_generate_token_explicit_username_overrides_default(encryption_mode):
"""An explicitly provided username should take precedence over the full name.""" """An explicitly provided username should take precedence over the full name."""
user = UserFactory(full_name="Jane Doe") user = UserFactory(full_name="Jane Doe")
token = generate_token(room="my-room", user=user, username="Custom Name") token = generate_token(
room="my-room",
user=user,
username="Custom Name",
encryption_mode=encryption_mode,
)
claims = decode_token(token) claims = decode_token(token)
assert claims["name"] == "Custom Name" assert claims["name"] == "Custom Name"
def test_authenticated_username_ignored_when_editing_disabled(settings): @pytest.mark.parametrize("encryption_mode", ["none", "basic"])
def test_authenticated_username_ignored_when_editing_disabled(
settings, encryption_mode
):
"""With editing disabled, an authenticated user's username is ignored.""" """With editing disabled, an authenticated user's username is ignored."""
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
user = UserFactory(full_name="Jane Doe") user = UserFactory(full_name="Jane Doe")
token = generate_token(room="my-room", user=user, username="Custom Name") token = generate_token(
room="my-room",
user=user,
username="Custom Name",
encryption_mode=encryption_mode,
)
claims = decode_token(token) claims = decode_token(token)
assert claims["name"] == "Jane Doe" assert claims["name"] == "Jane Doe"
+15 -3
View File
@@ -34,6 +34,9 @@ from livekit.api import ( # pylint: disable=E0611
TwirpError, TwirpError,
VideoGrants, VideoGrants,
) )
from livekit.protocol.room import RoomConfiguration # pylint: disable=E0611
from core.enums import EncryptionMode
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -69,6 +72,7 @@ def generate_token( # noqa: PLR0917
role: Optional[str] = None, role: Optional[str] = None,
participant_id: Optional[str] = None, participant_id: Optional[str] = None,
ttl: Optional[timedelta] = None, ttl: Optional[timedelta] = None,
encryption_mode: str = "none",
) -> str: ) -> str:
"""Generate a LiveKit access token for a user in a specific room. """Generate a LiveKit access token for a user in a specific room.
@@ -91,10 +95,8 @@ def generate_token( # noqa: PLR0917
""" """
is_admin_or_owner = role in ("owner", "administrator") is_admin_or_owner = role in ("owner", "administrator")
if is_admin_or_owner:
sources = settings.LIVEKIT_DEFAULT_SOURCES
if sources is None: if is_admin_or_owner or sources is None:
sources = settings.LIVEKIT_DEFAULT_SOURCES sources = settings.LIVEKIT_DEFAULT_SOURCES
video_grants = VideoGrants( video_grants = VideoGrants(
@@ -141,6 +143,14 @@ def generate_token( # noqa: PLR0917
if ttl is not None: if ttl is not None:
token = token.with_ttl(ttl) token = token.with_ttl(ttl)
if encryption_mode != EncryptionMode.NONE:
token = token.with_room_config(
RoomConfiguration(
name=room,
metadata=json.dumps({"encryption_mode": encryption_mode}),
)
)
return token.to_jwt() return token.to_jwt()
@@ -152,6 +162,7 @@ def generate_livekit_config( # noqa: PLR0917
color: Optional[str] = None, color: Optional[str] = None,
configuration: Optional[dict] = None, configuration: Optional[dict] = None,
participant_id: Optional[str] = None, participant_id: Optional[str] = None,
encryption_mode: str = "none",
) -> dict: ) -> dict:
"""Generate LiveKit configuration for room access. """Generate LiveKit configuration for room access.
@@ -184,6 +195,7 @@ def generate_livekit_config( # noqa: PLR0917
sources=sources, sources=sources,
role=role, role=role,
participant_id=participant_id, participant_id=participant_id,
encryption_mode=encryption_mode,
), ),
} }
+4
View File
@@ -978,6 +978,10 @@ class Base(Configuration):
environ_prefix=None, environ_prefix=None,
) )
ENCRYPTION_ENABLED = values.BooleanValue(
False, environ_name="ENCRYPTION_ENABLED", environ_prefix=None
)
# External Applications # External Applications
APPLICATION_ENABLED = values.BooleanValue( APPLICATION_ENABLED = values.BooleanValue(
False, environ_name="APPLICATION_ENABLED", environ_prefix=None False, environ_name="APPLICATION_ENABLED", environ_prefix=None