mirror of
https://github.com/suitenumerique/meet.git
synced 2026-10-13 00:11:36 +00:00
b4b2cb8da6
Before this commit: 1. Rooms could be hard-deleted from the internal API (unused by the frontend). 2. The external API had no way to delete a room. 3. The admin could hard-delete rooms. What this commit does: 1. Add a `deleted_at` field to Room. `Room.objects` hides soft-deleted rooms; `all_objects` stays the default manager so deleted rooms keep reserving their slug and PIN code. 2. Soft delete rooms from the internal API (replacing hard delete) and the external API (new DELETE, owner only, `rooms:delete` scope). The LiveKit room is closed and its participants disconnected; if closing fails, the soft delete is rolled back and an error is returned. 3. Answer 410 Gone on detail operations on a deleted room (after permission checks). Its can still be read but not modified. 4. Close LiveKit rooms recreated after deletion by participants still holding a valid token (room_started webhook). 5. Frontend: participants in the call land on the feedback page without the rejoin button, lobby participants see a "meeting deleted" screen, and opening the link shows an error instead of recreating the room. Untouched by this commit: - The admin still hard-deletes rooms, without closing the LiveKit room, and lists soft-deleted rooms alongside active ones. - Purging soft-deleted rooms (hard deletion) is not handled. - Scheduled deletion is not handled.
356 lines
12 KiB
Python
356 lines
12 KiB
Python
"""External API endpoints"""
|
|
|
|
import copy
|
|
from logging import getLogger
|
|
|
|
from django.conf import settings
|
|
from django.core.exceptions import ValidationError
|
|
from django.core.validators import validate_email
|
|
|
|
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
|
from rest_framework import decorators, mixins, viewsets
|
|
from rest_framework import (
|
|
exceptions as drf_exceptions,
|
|
)
|
|
from rest_framework import (
|
|
parsers as drf_parsers,
|
|
)
|
|
from rest_framework import (
|
|
response as drf_response,
|
|
)
|
|
from rest_framework import (
|
|
status as drf_status,
|
|
)
|
|
from rest_framework.generics import get_object_or_404
|
|
|
|
from core import analytics, api, models
|
|
from core.api.exceptions import ensure_room_not_deleted
|
|
from core.api.feature_flag import FeatureFlag
|
|
from core.services.jwt_token import JwtTokenService
|
|
from core.services.room_management import RoomManagement, RoomManagementException
|
|
from core.services.transit_code import TransitCodeService
|
|
|
|
from ..services.provisional_user_service import (
|
|
ProvisionalUserCreationDisabledError,
|
|
ProvisionalUserIntegrityError,
|
|
ProvisionalUserService,
|
|
)
|
|
from . import authentication, permissions, serializers
|
|
|
|
logger = getLogger(__name__)
|
|
|
|
|
|
class ApplicationViewSet(viewsets.ViewSet):
|
|
"""API endpoints for application authentication and token generation."""
|
|
|
|
@decorators.action(
|
|
detail=False,
|
|
methods=["post"],
|
|
url_path="token",
|
|
url_name="token",
|
|
parser_classes=[drf_parsers.FormParser, drf_parsers.JSONParser],
|
|
)
|
|
@FeatureFlag.require("application")
|
|
def generate_jwt_access_token(self, request, *args, **kwargs):
|
|
"""Generate JWT access token for application delegation.
|
|
|
|
Validates application credentials and generates a JWT token scoped
|
|
to a specific user email, allowing the application to act on behalf
|
|
of that user.
|
|
|
|
Note: The 'scope' parameter accepts an email address to identify the user
|
|
being delegated. This design allows applications to obtain user-scoped tokens
|
|
for delegation purposes. The scope field is intentionally generic and can be
|
|
extended to support other values in the future.
|
|
|
|
Reference: https://stackoverflow.com/a/27711422
|
|
"""
|
|
serializer = serializers.ApplicationJwtSerializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
|
|
client_id = serializer.validated_data["client_id"]
|
|
client_secret = serializer.validated_data["client_secret"]
|
|
|
|
try:
|
|
application = models.Application.objects.get(client_id=client_id)
|
|
except models.Application.DoesNotExist as e:
|
|
raise drf_exceptions.AuthenticationFailed("Invalid credentials") from e
|
|
|
|
if not application.check_client_secret(client_secret):
|
|
raise drf_exceptions.AuthenticationFailed("Invalid credentials")
|
|
|
|
if not application.is_active:
|
|
raise drf_exceptions.AuthenticationFailed("Application is inactive")
|
|
|
|
email = serializer.validated_data["scope"]
|
|
try:
|
|
validate_email(email)
|
|
except ValidationError:
|
|
return drf_response.Response(
|
|
{
|
|
"error": "Scope should be a valid email address.",
|
|
},
|
|
status=drf_status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
if not application.can_delegate_email(email):
|
|
logger.warning(
|
|
"Application %s denied delegation for %s",
|
|
application.client_id,
|
|
email,
|
|
)
|
|
return drf_response.Response(
|
|
{
|
|
"error": "This application is not authorized for this email domain.",
|
|
},
|
|
status=drf_status.HTTP_403_FORBIDDEN,
|
|
)
|
|
|
|
try:
|
|
user, _ = ProvisionalUserService().get_or_create(email, client_id)
|
|
except ProvisionalUserCreationDisabledError as not_found_error:
|
|
raise drf_exceptions.NotFound("User not found.") from not_found_error
|
|
except ProvisionalUserIntegrityError:
|
|
return drf_response.Response(
|
|
{"error": "Failed to create or retrieve provisional user."},
|
|
status=drf_status.HTTP_409_CONFLICT,
|
|
)
|
|
|
|
scope = " ".join(application.scopes or [])
|
|
|
|
token_service = JwtTokenService(
|
|
secret_key=settings.APPLICATION_JWT_SECRET_KEY,
|
|
algorithm=settings.APPLICATION_JWT_ALG,
|
|
issuer=settings.APPLICATION_JWT_ISSUER,
|
|
audience=settings.APPLICATION_JWT_AUDIENCE,
|
|
expiration_seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS,
|
|
token_type=settings.APPLICATION_JWT_TOKEN_TYPE,
|
|
)
|
|
|
|
data = token_service.generate_jwt(
|
|
user,
|
|
scope,
|
|
{
|
|
"client_id": client_id,
|
|
"delegated": True,
|
|
},
|
|
)
|
|
|
|
return drf_response.Response(
|
|
data,
|
|
status=drf_status.HTTP_200_OK,
|
|
)
|
|
|
|
|
|
class RoomViewSet(
|
|
mixins.CreateModelMixin,
|
|
mixins.DestroyModelMixin,
|
|
mixins.RetrieveModelMixin,
|
|
mixins.ListModelMixin,
|
|
mixins.UpdateModelMixin,
|
|
viewsets.GenericViewSet,
|
|
):
|
|
"""Application-delegated API for room management.
|
|
|
|
Provides JWT-authenticated access to room operations for external applications
|
|
acting on behalf of users. All operations are scope-based and filtered to the
|
|
authenticated user's accessible rooms.
|
|
|
|
Supported operations:
|
|
- list: List rooms the user has access to (requires 'rooms:list' scope)
|
|
- retrieve: Get room details (requires 'rooms:retrieve' scope)
|
|
- create: Create a new room owned by the user (requires 'rooms:create' scope)
|
|
- partial_update: Update a room's access level and configuration, for
|
|
administrators and owners only (requires 'rooms:update' scope)
|
|
- destroy: Soft delete a room and close its LiveKit room, for owners only
|
|
(requires 'rooms:delete' scope)
|
|
|
|
Detail operations on a soft-deleted room answer 410 Gone.
|
|
"""
|
|
|
|
http_method_names = ["get", "post", "patch", "delete", "head", "options"]
|
|
|
|
authentication_classes = [
|
|
authentication.ApplicationJWTAuthentication,
|
|
authentication.AddonsJWTAuthentication,
|
|
ResourceServerAuthentication,
|
|
]
|
|
permission_classes = [
|
|
api.permissions.IsAuthenticated
|
|
& permissions.HasRequiredRoomScope
|
|
& permissions.RoomPermissions
|
|
]
|
|
queryset = models.Room.objects.all()
|
|
serializer_class = serializers.RoomSerializer
|
|
|
|
def get_object(self):
|
|
"""Get the room, answer 410 if it has been deleted.
|
|
|
|
Permissions are checked first so a deleted room is only revealed to
|
|
users who would have been granted access to it.
|
|
"""
|
|
room = get_object_or_404(models.Room.all_objects, pk=self.kwargs["pk"])
|
|
self.check_object_permissions(self.request, room)
|
|
ensure_room_not_deleted(room)
|
|
return room
|
|
|
|
def list(self, request, *args, **kwargs):
|
|
"""Limit listed rooms to the ones related to the authenticated user."""
|
|
|
|
user = self.request.user
|
|
|
|
if user.is_authenticated:
|
|
queryset = (
|
|
self.filter_queryset(self.get_queryset()).filter(users=user).distinct()
|
|
)
|
|
else:
|
|
queryset = self.get_queryset().none()
|
|
|
|
page = self.paginate_queryset(queryset)
|
|
if page is not None:
|
|
serializer = self.get_serializer(page, many=True)
|
|
return self.get_paginated_response(serializer.data)
|
|
|
|
serializer = self.get_serializer(queryset, many=True)
|
|
return drf_response.Response(serializer.data)
|
|
|
|
def _track_room_event(self, room, event, **extra_properties):
|
|
"""Log a room operation for auditing and forward it to analytics."""
|
|
|
|
auth_method = type(self.request.successful_authenticator).__name__
|
|
client_id = (self.request.auth or {}).get("client_id", "unknown")
|
|
|
|
# Log for auditing
|
|
details = "".join(f", {key}={value}" for key, value in extra_properties.items())
|
|
logger.info(
|
|
"Room %s via application: room_id=%s, user_id=%s, client_id=%s, auth_method=%s%s",
|
|
event.removeprefix("room_"),
|
|
room.id,
|
|
self.request.user.id,
|
|
client_id,
|
|
auth_method,
|
|
details,
|
|
)
|
|
|
|
analytics.capture(
|
|
self.request.user,
|
|
event,
|
|
{
|
|
"room_id": str(room.pk),
|
|
"access_level": room.access_level,
|
|
"client_id": client_id,
|
|
"external_api": True,
|
|
"auth_method": auth_method,
|
|
**extra_properties,
|
|
"$set": {"email": self.request.user.email},
|
|
},
|
|
)
|
|
|
|
def perform_create(self, serializer: serializers.RoomSerializer):
|
|
"""Set the current user as owner of the newly created room."""
|
|
room = serializer.save()
|
|
models.ResourceAccess.objects.create(
|
|
resource=room,
|
|
user=self.request.user,
|
|
role=models.RoleChoices.OWNER,
|
|
)
|
|
|
|
self._track_room_event(room, analytics.AnalyticsEvent.ROOM_CREATED)
|
|
|
|
def perform_destroy(self, instance):
|
|
"""Soft delete the room, close its LiveKit room, then log and track it."""
|
|
try:
|
|
RoomManagement.soft_delete(instance)
|
|
except RoomManagementException as e:
|
|
raise drf_exceptions.APIException(
|
|
"Could not delete the room, please try again."
|
|
) from e
|
|
self._track_room_event(instance, analytics.AnalyticsEvent.ROOM_DELETED)
|
|
|
|
def perform_update(self, serializer: serializers.RoomSerializer):
|
|
"""Persist the room update, sync it to LiveKit, then log and track it."""
|
|
|
|
previous_values = {
|
|
"access_level": serializer.instance.access_level,
|
|
"configuration": copy.deepcopy(serializer.instance.configuration),
|
|
}
|
|
|
|
room = serializer.save()
|
|
|
|
# Report the fields that actually changed, not the ones that were submitted.
|
|
updated_fields = sorted(
|
|
field
|
|
for field, previous_value in previous_values.items()
|
|
if getattr(room, field) != previous_value
|
|
)
|
|
|
|
if updated_fields:
|
|
RoomManagement.sync_room_metadata(room)
|
|
|
|
self._track_room_event(
|
|
room,
|
|
analytics.AnalyticsEvent.ROOM_UPDATED,
|
|
updated_fields=updated_fields,
|
|
previous_access_level=previous_values["access_level"],
|
|
)
|
|
|
|
|
|
class UserViewSet(viewsets.GenericViewSet):
|
|
"""Application-delegated API for user operations.
|
|
|
|
Provides JWT-authenticated access to user operations for external
|
|
applications acting on behalf of users. All operations are
|
|
scope-based. Meant to grow with the other user actions exposed to
|
|
third parties.
|
|
|
|
Supported operations:
|
|
- transit-code: Mint a single-use transit code for the delegated user
|
|
(requires 'users:session' scope)
|
|
"""
|
|
|
|
authentication_classes = [
|
|
authentication.ApplicationJWTAuthentication,
|
|
]
|
|
permission_classes = [
|
|
api.permissions.IsAuthenticated & permissions.HasRequiredUserScope
|
|
]
|
|
|
|
@decorators.action(
|
|
detail=False,
|
|
methods=["post"],
|
|
url_path="transit-code",
|
|
url_name="transit-code",
|
|
)
|
|
@FeatureFlag.require("user_access_token")
|
|
def generate_transit_code(self, request):
|
|
"""Mint a transit code for the delegated user.
|
|
|
|
Returns a short-lived, single-use opaque code to pass to an embedded
|
|
frontend (e.g. via a URL fragment when cookies are unavailable). The
|
|
frontend exchanges it once on
|
|
POST /api/v1.0/users/exchange-access-token/ for a JWT access token,
|
|
equivalent to session-cookie authentication and never exposed in a URL.
|
|
"""
|
|
|
|
if not request.auth or not request.auth.get("client_id"):
|
|
raise drf_exceptions.AuthenticationFailed("Invalid application token.")
|
|
|
|
client_id = request.auth["client_id"]
|
|
|
|
code = TransitCodeService().create_code(request.user, client_id=client_id)
|
|
|
|
# Log for auditing
|
|
logger.info(
|
|
"Transit code issued: user_id=%s, client_id=%s",
|
|
request.user.id,
|
|
client_id,
|
|
)
|
|
|
|
return drf_response.Response(
|
|
{
|
|
"transit_code": code,
|
|
"expires_in": settings.TRANSIT_CODE_TTL,
|
|
},
|
|
status=drf_status.HTTP_200_OK,
|
|
)
|