This commit is contained in:
lebaudantoine
2025-10-02 18:28:35 +02:00
parent 2b884e410c
commit 5f90e580e3
13 changed files with 359 additions and 24 deletions
+1 -4
View File
@@ -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
+44
View File
@@ -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
-18
View File
@@ -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,
)
@@ -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)
@@ -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
@@ -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)
+98
View File
@@ -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
+17 -2
View File
@@ -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)
+30
View File
@@ -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):
@@ -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:
@@ -1,4 +1,5 @@
djangoSecretKey: 7K9mQ2xR8pL3vN6tY1sW4jH5cE0zF9bM2qA7uI3oP6rT1wErt12te12
integrationJwtSecretKey: devKey
livekit:
keys:
devkey: secret
+14
View File
@@ -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 }}