From 5f90e580e3659d5771b63d006f621b77bd7ffef2 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Sat, 27 Sep 2025 22:16:19 +0200 Subject: [PATCH] wip --- src/backend/core/api/permissions.py | 5 +- src/backend/core/api/serializers.py | 44 +++++++++ src/backend/core/api/viewsets.py | 18 ---- src/backend/core/external_api/__init__.py | 0 .../core/external_api/authentication.py | 67 +++++++++++++ src/backend/core/external_api/permissions.py | 20 ++++ src/backend/core/external_api/serializers.py | 65 ++++++++++++ src/backend/core/external_api/viewsets.py | 98 +++++++++++++++++++ src/backend/core/urls.py | 19 +++- src/backend/meet/settings.py | 30 ++++++ .../dev-keycloak/values.meet.yaml.gotmpl | 2 + .../env.d/dev-keycloak/values.secrets.yaml | 1 + src/helm/meet/templates/ingress.yaml | 14 +++ 13 files changed, 359 insertions(+), 24 deletions(-) create mode 100644 src/backend/core/external_api/__init__.py create mode 100644 src/backend/core/external_api/authentication.py create mode 100644 src/backend/core/external_api/permissions.py create mode 100644 src/backend/core/external_api/serializers.py create mode 100644 src/backend/core/external_api/viewsets.py diff --git a/src/backend/core/api/permissions.py b/src/backend/core/api/permissions.py index 1b0a881b..70b06d25 100644 --- a/src/backend/core/api/permissions.py +++ b/src/backend/core/api/permissions.py @@ -1,9 +1,8 @@ """Permission handlers for the Meet core app.""" from rest_framework import permissions -from rest_framework_api_key.permissions import BaseHasAPIKey -from ..models import RoleChoices, ServiceAccountAPIKey +from ..models import RoleChoices ACTION_FOR_METHOD_TO_PERMISSION = { "versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"} @@ -108,5 +107,3 @@ class HasLiveKitRoomAccess(permissions.BasePermission): return False return request.auth.video.room == str(obj.id) -class HasServiceAccountAPIKey(BaseHasAPIKey): - model = ServiceAccountAPIKey diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 3b0743e4..ef144510 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -301,3 +301,47 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer): ) from e return attrs + +class IntegrationJwtSerializer(BaseValidationOnlySerializer): + """Validate room creation callback data.""" + + email = serializers.EmailField(required=True) + +class IntegrationRoomSerializer(serializers.ModelSerializer): + """Serialize Room model for the API.""" + + class Meta: + model = models.Room + fields = ["id", "name", "slug", "configuration", "access_level", "pin_code"] + read_only_fields = ["id", "slug", "pin_code"] + + def to_representation(self, instance): + """ + Add users only for administrator users. + Add LiveKit credentials for public instance or related users/groups + """ + output = super().to_representation(instance) + request = self.context.get("request") + + if not request: + return output + + role = instance.get_role(request.user) + is_admin_or_owner = models.RoleChoices.check_administrator_role( + role + ) or models.RoleChoices.check_owner_role(role) + + if is_admin_or_owner: + access_serializer = NestedResourceAccessSerializer( + instance.accesses.select_related("resource", "user").all(), + context=self.context, + many=True, + ) + output["accesses"] = access_serializer.data + + if not is_admin_or_owner: + del output["configuration"] + + output["is_administrable"] = is_admin_or_owner + + return output diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 06ba176e..55578e9e 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -863,21 +863,3 @@ class RecordingViewSet( request = utils.generate_s3_authorization_headers(recording.key) return drf_response.Response("authorized", headers=request.headers, status=200) - -class WipViewSet(viewsets.GenericViewSet): - """Wip.""" - - permission_classes = [permissions.HasServiceAccountAPIKey] - - @decorators.action( - detail=False, - methods=["get"], - url_path="ping", - url_name="ping", - ) - def ping(self, request, *args, **kwargs): - """Wip.""" - return drf_response.Response( - {"message": "pong"}, - status=drf_status.HTTP_200_OK, - ) diff --git a/src/backend/core/external_api/__init__.py b/src/backend/core/external_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/core/external_api/authentication.py b/src/backend/core/external_api/authentication.py new file mode 100644 index 00000000..6ebd7b2a --- /dev/null +++ b/src/backend/core/external_api/authentication.py @@ -0,0 +1,67 @@ +"""Wip.""" + +import logging +import jwt +from django.conf import settings +from django.contrib.auth import get_user_model +from rest_framework import authentication, exceptions + +User = get_user_model() + + +logger = logging.getLogger(__name__) + +class IntegrationJWTAuthentication(authentication.BaseAuthentication): + """ + Simple JWT authentication for external-api endpoints. + """ + + def authenticate(self, request): + """Wip.""" + + auth_header = authentication.get_authorization_header(request).split() + + if not auth_header or auth_header[0].lower() != b'bearer': + return None + + if len(auth_header) != 2: + raise exceptions.AuthenticationFailed('Invalid token header.') + + try: + token = auth_header[1].decode('utf-8') + except UnicodeError: + raise exceptions.AuthenticationFailed('Invalid token.') + + return self.authenticate_credentials(token) + + def authenticate_credentials(self, token): + """Wip.""" + + try: + payload = jwt.decode( + token, + settings.INTEGRATIONS_JWT_SECRET_KEY, + algorithms=[settings.INTEGRATIONS_JWT_ALG], + audience="wip" + ) + except jwt.ExpiredSignatureError: + logger.error("Token expired") + raise exceptions.AuthenticationFailed('Token expired.') + except jwt.InvalidTokenError as e: + logger.error("Invalid JWT token: %s", e) + raise exceptions.AuthenticationFailed('Invalid token.') + + if not payload.get("user_id"): + logger.warning("Invalid JWT token. Missing 'user_id' in payload") + return None + + try: + user = User.objects.get(id=payload['user_id']) + except User.DoesNotExist: + logger.warning("User not found") + raise exceptions.AuthenticationFailed('User not found.') + + if not user.is_active: + logger.warning("User inactive") + raise exceptions.AuthenticationFailed('User inactive.') + return (user, token) \ No newline at end of file diff --git a/src/backend/core/external_api/permissions.py b/src/backend/core/external_api/permissions.py new file mode 100644 index 00000000..b6b8a49b --- /dev/null +++ b/src/backend/core/external_api/permissions.py @@ -0,0 +1,20 @@ +"""Wip.""" + +from rest_framework import permissions + +from rest_framework_api_key.permissions import BaseHasAPIKey + +from ..models import ServiceAccountAPIKey + + + +class HasServiceAccountAPIKey(BaseHasAPIKey): + model = ServiceAccountAPIKey + +class IsAuthenticated(permissions.BasePermission): + """ + Allows access only to authenticated users. Alternative method checking the presence + of the auth token to avoid hitting the database. + """ + def has_permission(self, request, view): + return bool(request.auth) or request.user.is_authenticated diff --git a/src/backend/core/external_api/serializers.py b/src/backend/core/external_api/serializers.py new file mode 100644 index 00000000..498e9c29 --- /dev/null +++ b/src/backend/core/external_api/serializers.py @@ -0,0 +1,65 @@ +"""Wip.""" + +# pylint: disable=abstract-method,no-name-in-module + +from django.conf import settings +from rest_framework import serializers + +import random +import string + +from core import models, utils + +from core.api.serializers import NestedResourceAccessSerializer, BaseValidationOnlySerializer + +class JwtSerializer(BaseValidationOnlySerializer): + """Validate room creation callback data.""" + + email = serializers.EmailField(required=True) + +class RoomSerializer(serializers.ModelSerializer): + """Serialize Room model for the API.""" + + class Meta: + model = models.Room + fields = ["id", "slug", "configuration", "pin_code", "access_level"] + read_only_fields = ["id", "name", "slug", "pin_code", "access_level"] + + def to_representation(self, instance): + """ + Add users only for administrator users. + Add LiveKit credentials for public instance or related users/groups + """ + output = super().to_representation(instance) + request = self.context.get("request") + + if not request: + return output + + output["url"] = f"{settings.INTEGRATIONS_APP_BASE_URL}/{output["slug"]}" + + if settings.ROOM_TELEPHONY_ENABLED: + output["telephony"] = { + "enabled": settings.ROOM_TELEPHONY_ENABLED, + "pin_code": output["pin_code"], + "phone_number": settings.ROOM_TELEPHONY_PHONE_NUMBER, + "default_country": settings.ROOM_TELEPHONY_DEFAULT_COUNTRY, + } + del output["pin_code"] + + return output + + def create(self, validated_data): + """Custom create method.""" + + # todo - extract this in an util function + def generate_pattern(): + part1 = ''.join(random.choices(string.ascii_lowercase, k=3)) + part2 = ''.join(random.choices(string.ascii_lowercase, k=4)) + part3 = ''.join(random.choices(string.ascii_lowercase, k=3)) + return f"{part1}-{part2}-{part3}" + + validated_data['name'] = generate_pattern() + validated_data['access_level'] = "trusted" + + return super().create(validated_data) diff --git a/src/backend/core/external_api/viewsets.py b/src/backend/core/external_api/viewsets.py new file mode 100644 index 00000000..434960d3 --- /dev/null +++ b/src/backend/core/external_api/viewsets.py @@ -0,0 +1,98 @@ +"""Wip.""" + +from logging import getLogger + +from django.conf import settings + +import jwt +from datetime import datetime, timedelta + +from rest_framework import decorators, mixins, pagination, throttling, viewsets +from rest_framework import ( + exceptions as drf_exceptions, +) +from rest_framework import ( + response as drf_response, +) +from rest_framework import ( + status as drf_status, +) + +from core import enums, models, utils +from .authentication import IntegrationJWTAuthentication +from . import serializers, permissions + +# pylint: disable=too-many-ancestors + +logger = getLogger(__name__) + + +class IntegrationViewSet(viewsets.GenericViewSet): + """Wip.""" + + permission_classes = [permissions.HasServiceAccountAPIKey] + + @decorators.action( + detail=False, + methods=["post"], + url_path="token", + url_name="token", + ) + def generate_token(self, request, *args, **kwargs): + """Generate JWT token for a specific user identified by email.""" + + serializer = serializers.JwtSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + try: + user = models.User.objects.get(email=serializer.validated_data["email"]) + except models.User.DoesNotExist: + # todo - create unknown user + raise drf_exceptions.NotFound({"error": "User with this email does not exist."}) + + now = datetime.utcnow() + payload = { + 'user_id': str(user.id), + 'email': user.email, + 'full_name': user.full_name, + 'iat': now, + 'exp': now + timedelta(seconds=settings.INTEGRATIONS_JWT_EXPIRATION_SECONDS), + 'iss': settings.INTEGRATIONS_JWT_ISSUER, + 'aud': 'wip', # todo - replace with the owner of the api token + # todo - add scope + } + + try: + token = jwt.encode( + payload, + settings.INTEGRATIONS_JWT_SECRET_KEY, + algorithm=settings.INTEGRATIONS_JWT_ALG + ) + except Exception as e: + return drf_response.Response( + {"error": "Failed to generate token"}, + status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + return drf_response.Response( + { + "token": token, + "token_type": settings.INTEGRATIONS_JWT_TOKEN_TYPE, + "expires_in": settings.INTEGRATIONS_JWT_EXPIRATION_SECONDS, + }, + status=drf_status.HTTP_200_OK, + ) + + +class RoomViewSet( + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet, +): + """Wip.""" + + authentication_classes = [IntegrationJWTAuthentication] + permission_classes = [permissions.IsAuthenticated] + queryset = models.Room.objects.all() + serializer_class = serializers.RoomSerializer diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index f98b4808..7a762f76 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -4,20 +4,24 @@ from django.conf import settings from django.urls import include, path from lasuite.oidc_login.urls import urlpatterns as oidc_urls -from rest_framework.routers import DefaultRouter +from rest_framework.routers import DefaultRouter, SimpleRouter from core.api import get_frontend_configuration, viewsets +from core.external_api import viewsets as external_viewsets # - Main endpoints router = DefaultRouter() router.register("users", viewsets.UserViewSet, basename="users") router.register("rooms", viewsets.RoomViewSet, basename="rooms") -router.register("wip", viewsets.WipViewSet, basename="wip") router.register("recordings", viewsets.RecordingViewSet, basename="recordings") router.register( "resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses" ) +external_router = DefaultRouter() +external_router.register("rooms", external_viewsets.RoomViewSet, basename="external_rooms") +external_router.register("integrations", external_viewsets.IntegrationViewSet, basename="external_integrations") + urlpatterns = [ path( f"api/{settings.API_VERSION}/", @@ -29,4 +33,15 @@ urlpatterns = [ ] ), ), + path( + f"external-api/{settings.EXTERNAL_API_VERSION}/", + include( + [ + *external_router.urls, + ] + ), + ), ] + +print('core') +print(external_router.urls) diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index 02fc5130..1561ccf0 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -69,6 +69,7 @@ class Base(Configuration): USE_SWAGGER = False API_VERSION = "v1.0" + EXTERNAL_API_VERSION = "v1.0" DATA_DIR = values.Value(path.join("/", "data"), environ_name="DATA_DIR") @@ -665,6 +666,35 @@ class Base(Configuration): environ_prefix=None, ) + # Integrations settings + INTEGRATIONS_JWT_SECRET_KEY = SecretFileValue(None, environ_name="INTEGRATIONS_JWT_SECRET_KEY", environ_prefix=None) + INTEGRATIONS_JWT_ALG = values.Value( + "HS256", + environ_name="INTEGRATIONS_JWT_ALG", + environ_prefix=None, + ) + INTEGRATIONS_JWT_ISSUER = values.Value( + "lasuite-meet", + environ_name="INTEGRATIONS_JWT_ISSUER", + environ_prefix=None, + ) + INTEGRATIONS_JWT_EXPIRATION_SECONDS = values.PositiveIntegerValue( + 3600, + environ_name="INTEGRATIONS_JWT_EXPIRATION_SECONDS", + environ_prefix=None, + ) + INTEGRATIONS_JWT_TOKEN_TYPE = values.Value( + "Bearer", + environ_name="INTEGRATIONS_JWT_TOKEN_TYPE", + environ_prefix=None, + ) + INTEGRATIONS_APP_BASE_URL = values.Value( + None, + environ_name="INTEGRATIONS_APP_BASE_URL", + environ_prefix=None, + ) + + # pylint: disable=invalid-name @property def ENVIRONMENT(self): diff --git a/src/helm/env.d/dev-keycloak/values.meet.yaml.gotmpl b/src/helm/env.d/dev-keycloak/values.meet.yaml.gotmpl index 40322953..1b7427d8 100644 --- a/src/helm/env.d/dev-keycloak/values.meet.yaml.gotmpl +++ b/src/helm/env.d/dev-keycloak/values.meet.yaml.gotmpl @@ -73,6 +73,8 @@ backend: ROOM_TELEPHONY_PHONE_NUMBER: '+33901020304' SSL_CERT_FILE: /usr/local/lib/python3.13/site-packages/certifi/cacert.pem ROOM_SUBTITLE_ENABLED: True + INTEGRATIONS_JWT_SECRET_KEY: {{ .Values.integrationJwtSecretKey }} + INTEGRATIONS_APP_BASE_URL: https://meet.127.0.0.1.nip.io migrate: diff --git a/src/helm/env.d/dev-keycloak/values.secrets.yaml b/src/helm/env.d/dev-keycloak/values.secrets.yaml index ad385b00..211b1464 100644 --- a/src/helm/env.d/dev-keycloak/values.secrets.yaml +++ b/src/helm/env.d/dev-keycloak/values.secrets.yaml @@ -1,4 +1,5 @@ djangoSecretKey: 7K9mQ2xR8pL3vN6tY1sW4jH5cE0zF9bM2qA7uI3oP6rT1wErt12te12 +integrationJwtSecretKey: devKey livekit: keys: devkey: secret diff --git a/src/helm/meet/templates/ingress.yaml b/src/helm/meet/templates/ingress.yaml index a6406560..44dfea6a 100644 --- a/src/helm/meet/templates/ingress.yaml +++ b/src/helm/meet/templates/ingress.yaml @@ -74,6 +74,20 @@ spec: serviceName: {{ include "meet.backend.fullname" . }} servicePort: {{ .Values.backend.service.port }} {{- end }} + - path: /external-api/ + {{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }} + pathType: Prefix + {{- end }} + backend: + {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} + service: + name: {{ include "meet.backend.fullname" . }} + port: + number: {{ .Values.backend.service.port }} + {{- else }} + serviceName: {{ include "meet.backend.fullname" . }} + servicePort: {{ .Values.backend.service.port }} + {{- end }} {{- with .Values.ingress.customBackends }} {{- toYaml . | nindent 10 }} {{- end }}