This commit is contained in:
lebaudantoine
2025-10-02 18:28:35 +02:00
parent 5f90e580e3
commit b64e5a0084
13 changed files with 201 additions and 62 deletions
+2
View File
@@ -65,3 +65,5 @@ ROOM_TELEPHONY_ENABLED=True
FRONTEND_USE_FRENCH_GOV_FOOTER=False
FRONTEND_USE_PROCONNECT_BUTTON=False
INTEGRATIONS_JWT_SECRET_KEY=devKey
+28 -1
View File
@@ -1,5 +1,6 @@
"""Admin classes and registrations for core app."""
from django import forms
from django.contrib import admin
from django.contrib.auth import admin as auth_admin
from django.utils.translation import gettext_lazy as _
@@ -154,13 +155,39 @@ class RecordingAdmin(admin.ModelAdmin):
return str(owners[0].user)
class ServiceAccountAdminForm(forms.ModelForm):
"""Wip."""
scopes = forms.MultipleChoiceField(
choices=models.ServiceAccountScope.choices,
widget=forms.CheckboxSelectMultiple,
required=False,
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.pk and self.instance.scopes:
self.fields["scopes"].initial = self.instance.scopes
@admin.register(models.ServiceAccount)
class ServiceAccountAdmin(admin.ModelAdmin):
"""Wip."""
list_display = ("id", "name")
form = ServiceAccountAdminForm
list_display = ("id", "name", "get_scopes_display")
fields = ["name", "id", "created_at", "updated_at", "scopes"]
readonly_fields = ["id", "created_at", "updated_at"]
def get_scopes_display(self, obj):
"""Display scopes in list view."""
if obj.scopes:
return ", ".join(obj.scopes)
return "No scopes"
get_scopes_display.short_description = "Scopes"
@admin.register(models.ServiceAccountAPIKey)
class OrganizationAPIKeyModelAdmin(APIKeyModelAdmin):
-1
View File
@@ -106,4 +106,3 @@ class HasLiveKitRoomAccess(permissions.BasePermission):
if not request.auth or not hasattr(request.auth, "video"):
return False
return request.auth.video.room == str(obj.id)
+2
View File
@@ -302,11 +302,13 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
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."""
+24 -15
View File
@@ -1,9 +1,11 @@
"""Wip."""
import logging
import jwt
from django.conf import settings
from django.contrib.auth import get_user_model
import jwt
from rest_framework import authentication, exceptions
User = get_user_model()
@@ -11,6 +13,7 @@ User = get_user_model()
logger = logging.getLogger(__name__)
class IntegrationJWTAuthentication(authentication.BaseAuthentication):
"""
Simple JWT authentication for external-api endpoints.
@@ -21,16 +24,18 @@ class IntegrationJWTAuthentication(authentication.BaseAuthentication):
auth_header = authentication.get_authorization_header(request).split()
if not auth_header or auth_header[0].lower() != b'bearer':
if not auth_header or auth_header[0].lower() != b"bearer":
return None
if len(auth_header) != 2:
raise exceptions.AuthenticationFailed('Invalid token header.')
logger.error("Invalid token header")
raise exceptions.AuthenticationFailed("Invalid token header.")
try:
token = auth_header[1].decode('utf-8')
except UnicodeError:
raise exceptions.AuthenticationFailed('Invalid token.')
token = auth_header[1].decode("utf-8")
except UnicodeError as e:
logger.error("Invalid: %s", e)
raise exceptions.AuthenticationFailed("Invalid token.") from e
return self.authenticate_credentials(token)
@@ -42,26 +47,30 @@ class IntegrationJWTAuthentication(authentication.BaseAuthentication):
token,
settings.INTEGRATIONS_JWT_SECRET_KEY,
algorithms=[settings.INTEGRATIONS_JWT_ALG],
audience="wip"
)
except jwt.ExpiredSignatureError:
except jwt.ExpiredSignatureError as e:
logger.error("Token expired")
raise exceptions.AuthenticationFailed('Token expired.')
raise exceptions.AuthenticationFailed("Token expired.") from e
except jwt.InvalidTokenError as e:
logger.error("Invalid JWT token: %s", e)
raise exceptions.AuthenticationFailed('Invalid token.')
raise exceptions.AuthenticationFailed("Invalid token.") from e
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:
user = User.objects.get(id=payload["user_id"])
except User.DoesNotExist as e:
logger.warning("User not found")
raise exceptions.AuthenticationFailed('User not found.')
raise exceptions.AuthenticationFailed("User not found.") from e
if not user.is_active:
logger.warning("User inactive")
raise exceptions.AuthenticationFailed('User inactive.')
return (user, token)
raise exceptions.AuthenticationFailed("User inactive.")
user.token_scopes = payload.get("scope", [])
user.is_impersonated = payload.get("impersonated", False)
user.client_id = payload.get("client_id")
return (user, token)
+38 -4
View File
@@ -1,20 +1,54 @@
"""Wip."""
from rest_framework import permissions
from rest_framework import exceptions, permissions
from rest_framework_api_key.permissions import BaseHasAPIKey
from ..models import ServiceAccountAPIKey
from ..models import ServiceAccountAPIKey, ServiceAccountScope
class HasServiceAccountAPIKey(BaseHasAPIKey):
"""Wip."""
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
class HasRequiredScope(permissions.BasePermission):
"""Check if JWT token has required scope for the action."""
# Map ViewSet actions to required scopes
scope_map = {
"list": ServiceAccountScope.ROOMS_LIST,
"retrieve": ServiceAccountScope.ROOMS_RETRIEVE,
"create": ServiceAccountScope.ROOMS_CREATE,
"update": ServiceAccountScope.ROOMS_UPDATE,
"partial_update": ServiceAccountScope.ROOMS_UPDATE,
"destroy": ServiceAccountScope.ROOMS_DELETE,
}
def has_permission(self, request, view):
"""Wip."""
action = getattr(view, "action", None)
if not action:
return True
required_scope = self.scope_map.get(action)
if not required_scope:
return True
token_scopes = getattr(request.user, "token_scopes", [])
if required_scope not in token_scopes:
raise exceptions.PermissionDenied("Insufficient permissions")
return True
+8 -14
View File
@@ -3,26 +3,25 @@
# 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 BaseValidationOnlySerializer
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"]
fields = ["id", "name", "slug", "configuration", "pin_code", "access_level"]
read_only_fields = ["id", "name", "slug", "pin_code", "access_level"]
def to_representation(self, instance):
@@ -36,7 +35,7 @@ class RoomSerializer(serializers.ModelSerializer):
if not request:
return output
output["url"] = f"{settings.INTEGRATIONS_APP_BASE_URL}/{output["slug"]}"
output["url"] = f"{settings.INTEGRATIONS_APP_BASE_URL}/{output['slug']}"
if settings.ROOM_TELEPHONY_ENABLED:
output["telephony"] = {
@@ -52,14 +51,9 @@ class RoomSerializer(serializers.ModelSerializer):
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}"
# todo - track source of creation
validated_data['name'] = generate_pattern()
validated_data['access_level'] = "trusted"
validated_data["name"] = utils.generate_slug()
validated_data["access_level"] = models.RoomAccessLevel.TRUSTED
return super().create(validated_data)
+59 -18
View File
@@ -1,13 +1,12 @@
"""Wip."""
from datetime import datetime, timedelta
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 decorators, mixins, viewsets
from rest_framework import (
exceptions as drf_exceptions,
)
@@ -18,9 +17,10 @@ from rest_framework import (
status as drf_status,
)
from core import enums, models, utils
from core import models
from . import permissions, serializers
from .authentication import IntegrationJWTAuthentication
from . import serializers, permissions
# pylint: disable=too-many-ancestors
@@ -44,31 +44,43 @@ class IntegrationViewSet(viewsets.GenericViewSet):
serializer = serializers.JwtSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
key = request.META["HTTP_AUTHORIZATION"].split()[1]
api_key = models.ServiceAccountAPIKey.objects.get_from_key(key)
service_account = api_key.service_account
# todo - extract all this logic in a service
# todo - check if email is allowed by the regex
try:
user = models.User.objects.get(email=serializer.validated_data["email"])
except models.User.DoesNotExist:
except models.User.DoesNotExist as e:
# todo - create unknown user
raise drf_exceptions.NotFound({"error": "User with this email does not exist."})
raise drf_exceptions.NotFound(
{"error": "User with this email does not exist."}
) from e
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
"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,
"impersonated": True,
"client_id": str(service_account.id), # audience
"scope": service_account.scopes,
}
try:
token = jwt.encode(
payload,
settings.INTEGRATIONS_JWT_SECRET_KEY,
algorithm=settings.INTEGRATIONS_JWT_ALG
algorithm=settings.INTEGRATIONS_JWT_ALG,
)
except Exception as e:
except Exception: # noqa: BLE001
return drf_response.Response(
{"error": "Failed to generate token"},
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -93,6 +105,35 @@ class RoomViewSet(
"""Wip."""
authentication_classes = [IntegrationJWTAuthentication]
permission_classes = [permissions.IsAuthenticated]
permission_classes = [permissions.IsAuthenticated, permissions.HasRequiredScope]
queryset = models.Room.objects.all()
serializer_class = serializers.RoomSerializer
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 perform_create(self, serializer):
"""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,
)
@@ -1,4 +1,4 @@
# Generated by Django 5.2.6 on 2025-09-11 16:03
# Generated by Django 5.2.6 on 2025-09-28 15:41
import django.db.models.deletion
import uuid
@@ -20,6 +20,7 @@ class Migration(migrations.Migration):
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('name', models.CharField(help_text='Descriptive name for this service account.', max_length=255, verbose_name='Service account name')),
('active', models.BooleanField(default=True)),
('scopes', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('rooms:create', 'Create rooms'), ('rooms:list', 'List rooms'), ('rooms:retrieve', 'Retrieve room details'), ('rooms:update', 'Update rooms'), ('rooms:delete', 'Delete rooms')], max_length=50), default=list, size=None)),
],
options={
'verbose_name': 'Service account',
+16
View File
@@ -11,6 +11,7 @@ from typing import List, Optional
from django.conf import settings
from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.postgres.fields import ArrayField
from django.core import mail, validators
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import models
@@ -720,6 +721,16 @@ class RecordingAccess(BaseAccess):
return self._get_abilities(self.recording, user)
class ServiceAccountScope(models.TextChoices):
"""Wip."""
ROOMS_CREATE = "rooms:create", _("Create rooms")
ROOMS_LIST = "rooms:list", _("List rooms")
ROOMS_RETRIEVE = "rooms:retrieve", _("Retrieve room details")
ROOMS_UPDATE = "rooms:update", _("Update rooms")
ROOMS_DELETE = "rooms:delete", _("Delete rooms")
class ServiceAccount(BaseModel):
"""Wip."""
@@ -730,6 +741,11 @@ class ServiceAccount(BaseModel):
)
active = models.BooleanField(default=True)
scopes = ArrayField(
models.CharField(max_length=50, choices=ServiceAccountScope.choices),
default=list,
)
class Meta:
db_table = "meet_service_account"
ordering = ("-created_at",)
+9 -6
View File
@@ -4,7 +4,7 @@ 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, SimpleRouter
from rest_framework.routers import DefaultRouter
from core.api import get_frontend_configuration, viewsets
from core.external_api import viewsets as external_viewsets
@@ -19,8 +19,14 @@ router.register(
)
external_router = DefaultRouter()
external_router.register("rooms", external_viewsets.RoomViewSet, basename="external_rooms")
external_router.register("integrations", external_viewsets.IntegrationViewSet, basename="external_integrations")
external_router.register(
"rooms", external_viewsets.RoomViewSet, basename="external_rooms"
)
external_router.register(
"integrations",
external_viewsets.IntegrationViewSet,
basename="external_integrations",
)
urlpatterns = [
path(
@@ -42,6 +48,3 @@ urlpatterns = [
),
),
]
print('core')
print(external_router.urls)
+10
View File
@@ -8,6 +8,8 @@ Utils functions used in the core app
import hashlib
import json
import random
import secrets
import string
from typing import List, Optional
from uuid import uuid4
@@ -27,6 +29,14 @@ from livekit.api import ( # pylint: disable=E0611
)
def generate_slug():
"""Wip."""
sizes = [3, 4, 3]
parts = ["".join(secrets.choices(string.ascii_lowercase, k=size)) for size in sizes]
return "-".join(parts)
def generate_color(identity: str) -> str:
"""Generates a consistent HSL color based on a given identity string.
+3 -2
View File
@@ -667,7 +667,9 @@ class Base(Configuration):
)
# Integrations settings
INTEGRATIONS_JWT_SECRET_KEY = SecretFileValue(None, environ_name="INTEGRATIONS_JWT_SECRET_KEY", environ_prefix=None)
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",
@@ -694,7 +696,6 @@ class Base(Configuration):
environ_prefix=None,
)
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):