mirror of
https://github.com/suitenumerique/meet.git
synced 2026-10-23 21:15:23 +00:00
702d6fefb7
Emit audit events across the recording lifecycle: recording.start, recording.stop and recording.delete from the API, recording.end when LiveKit reports the egress ended, recording.transcript.request when the backend sends a recording to the summary service, and recording.transcript.report and recording.summary.report when that service calls back. recording.end has an unknown outcome when LiveKit reports an egress status the backend does not map, and recording.transcript.request names the summary service as service.target. Small bugfix: AuditViewMixin keeps a copy of a deleted target, since deleting an instance clears its primary key.
398 lines
14 KiB
Python
398 lines
14 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, audit, auditing, models
|
|
from core.api.exceptions import ensure_room_not_deleted
|
|
from core.api.feature_flag import FeatureFlag
|
|
from core.api.locks import room_locked_for_update
|
|
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(audit.AuditViewMixin, viewsets.ViewSet):
|
|
"""API endpoints for application authentication and token generation."""
|
|
|
|
audit_actor = None
|
|
audit_claimed_client_id = None
|
|
|
|
@decorators.action(
|
|
detail=False,
|
|
methods=["post"],
|
|
url_path="token",
|
|
url_name="token",
|
|
parser_classes=[drf_parsers.FormParser, drf_parsers.JSONParser],
|
|
audit_action=auditing.APPLICATION_TOKEN_ISSUE,
|
|
)
|
|
@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"]
|
|
email = serializer.validated_data["scope"]
|
|
|
|
self.audit_claimed_client_id = client_id
|
|
self.audit_details = {"requested_domain": audit.email_domain(email)}
|
|
|
|
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")
|
|
|
|
self.audit_target = application
|
|
|
|
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):
|
|
return drf_response.Response(
|
|
{
|
|
"error": "This application is not authorized for this email domain.",
|
|
},
|
|
status=drf_status.HTTP_403_FORBIDDEN,
|
|
)
|
|
|
|
try:
|
|
user, created = 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,
|
|
},
|
|
)
|
|
|
|
self.audit_actor = user
|
|
self.audit_details = {
|
|
"scopes": list(application.scopes or []),
|
|
"user_provisioned": created,
|
|
"expires_in": settings.APPLICATION_JWT_EXPIRATION_SECONDS,
|
|
}
|
|
|
|
return drf_response.Response(
|
|
data,
|
|
status=drf_status.HTTP_200_OK,
|
|
)
|
|
|
|
def get_audit_fields(self, status_code, error=None):
|
|
"""Report the application as the actor once its credentials are verified.
|
|
|
|
Until then the submitted client id is only a claim: it is kept apart so
|
|
that it never names the application or the tenant of the event. Either
|
|
way, a session the request carries never makes its account the actor.
|
|
"""
|
|
application = self.audit_target
|
|
fields = {
|
|
**super().get_audit_fields(status_code, error),
|
|
"auth_method": "client_credentials",
|
|
"actor_type": audit.ActorType.ANONYMOUS,
|
|
}
|
|
if application:
|
|
fields |= {
|
|
"actor_type": audit.ActorType.APPLICATION,
|
|
"client_id": application.client_id,
|
|
}
|
|
else:
|
|
fields["claimed_client_id"] = self.audit_claimed_client_id
|
|
return fields
|
|
|
|
|
|
class RoomViewSet( # pylint: disable=too-many-ancestors
|
|
audit.AuditViewMixin,
|
|
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"]
|
|
|
|
audit_actions = {
|
|
"list": auditing.ROOM_LIST,
|
|
"retrieve": auditing.ROOM_RETRIEVE,
|
|
"create": auditing.ROOM_CREATE,
|
|
"partial_update": auditing.ROOM_UPDATE,
|
|
"destroy": auditing.ROOM_DELETE,
|
|
}
|
|
|
|
authentication_classes = [
|
|
authentication.ApplicationJWTAuthentication,
|
|
authentication.AddonsJWTAuthentication,
|
|
authentication.MenshenAuthentication,
|
|
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)
|
|
self.audit_details = {"total": self.paginator.page.paginator.count}
|
|
return self.get_paginated_response(serializer.data)
|
|
|
|
serializer = self.get_serializer(queryset, many=True)
|
|
self.audit_details = {"total": len(serializer.data)}
|
|
return drf_response.Response(serializer.data)
|
|
|
|
def _track_room_event(self, room, event, **extra_properties):
|
|
"""Add a room operation to the audit event and forward it to analytics."""
|
|
|
|
self.audit_target = room
|
|
self.audit_details = extra_properties
|
|
|
|
auth_method = type(self.request.successful_authenticator).__name__
|
|
client_id = (self.request.auth or {}).get("client_id", "unknown")
|
|
|
|
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()
|
|
self.audit_target = room
|
|
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 under a row lock, sync it to LiveKit, then track it."""
|
|
|
|
with room_locked_for_update(serializer.instance) as locked_room:
|
|
serializer.instance = locked_room
|
|
|
|
previous_values = {
|
|
"access_level": locked_room.access_level,
|
|
"configuration": copy.deepcopy(locked_room.configuration),
|
|
}
|
|
|
|
room = serializer.save()
|
|
|
|
self.audit_target = room
|
|
|
|
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,
|
|
)
|