mirror of
https://github.com/suitenumerique/meet.git
synced 2026-10-02 15:38:22 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 387269d1d2 |
@@ -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)
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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"""
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user