From 3c52fa22f528fc49b970e694a81a767c16e23e2d Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Sun, 28 Sep 2025 23:03:06 +0200 Subject: [PATCH] wip --- src/backend/core/admin.py | 10 ++--- src/backend/core/external_api/__init__.py | 1 + .../core/external_api/authentication.py | 40 +++++++++---------- src/backend/core/external_api/permissions.py | 31 +++++++------- src/backend/core/external_api/serializers.py | 22 ++++++---- src/backend/core/external_api/viewsets.py | 34 ++++++++++------ src/backend/core/urls.py | 6 +-- src/backend/core/utils.py | 2 +- 8 files changed, 83 insertions(+), 63 deletions(-) diff --git a/src/backend/core/admin.py b/src/backend/core/admin.py index 26909354..74dc9153 100644 --- a/src/backend/core/admin.py +++ b/src/backend/core/admin.py @@ -156,14 +156,14 @@ class RecordingAdmin(admin.ModelAdmin): class ServiceAccountDomainInline(admin.TabularInline): - """Wip.""" + """Inline admin for managing allowed domains per service account.""" model = models.ServiceAccountDomain extra = 0 class ServiceAccountAdminForm(forms.ModelForm): - """Wip.""" + """Custom form for ServiceAccount admin with multi-select scopes.""" scopes = forms.MultipleChoiceField( choices=models.ServiceAccountScope.choices, @@ -179,7 +179,7 @@ class ServiceAccountAdminForm(forms.ModelForm): @admin.register(models.ServiceAccount) class ServiceAccountAdmin(admin.ModelAdmin): - """Wip.""" + """Admin interface for managing service accounts and their permissions.""" form = ServiceAccountAdminForm @@ -198,8 +198,8 @@ class ServiceAccountAdmin(admin.ModelAdmin): @admin.register(models.ServiceAccountAPIKey) -class OrganizationAPIKeyModelAdmin(APIKeyModelAdmin): - """Wip.""" +class ServiceAccountAPIKeyModelAdmin(APIKeyModelAdmin): + """Admin interface for managing service account API keys.""" list_display = [*APIKeyModelAdmin.list_display, "service_account__name"] search_fields = [*APIKeyModelAdmin.search_fields, "service_account__name"] diff --git a/src/backend/core/external_api/__init__.py b/src/backend/core/external_api/__init__.py index e69de29b..cc93e5bb 100644 --- a/src/backend/core/external_api/__init__.py +++ b/src/backend/core/external_api/__init__.py @@ -0,0 +1 @@ +"""Meet core external API endpoints""" diff --git a/src/backend/core/external_api/authentication.py b/src/backend/core/external_api/authentication.py index b9e2f758..f5819120 100644 --- a/src/backend/core/external_api/authentication.py +++ b/src/backend/core/external_api/authentication.py @@ -1,4 +1,4 @@ -"""Wip.""" +"""Authentication Backends for service accounts integrated to the Meet core app.""" import logging @@ -14,13 +14,11 @@ User = get_user_model() logger = logging.getLogger(__name__) -class IntegrationJWTAuthentication(authentication.BaseAuthentication): - """ - Simple JWT authentication for external-api endpoints. - """ +class ServiceAccountJWTAuthentication(authentication.BaseAuthentication): + """JWT authentication for external API endpoints.""" def authenticate(self, request): - """Wip.""" + """Extract and validate JWT from Authorization header.""" auth_header = authentication.get_authorization_header(request).split() @@ -28,19 +26,19 @@ class IntegrationJWTAuthentication(authentication.BaseAuthentication): return None if len(auth_header) != 2: - logger.error("Invalid token header") + logger.warning("Invalid token header") raise exceptions.AuthenticationFailed("Invalid token header.") try: token = auth_header[1].decode("utf-8") except UnicodeError as e: - logger.error("Invalid: %s", e) + logger.warning("Invalid: %s", e) raise exceptions.AuthenticationFailed("Invalid token.") from e return self.authenticate_credentials(token) def authenticate_credentials(self, token): - """Wip.""" + """Authenticate and validate JWT token credentials.""" try: payload = jwt.decode( @@ -50,29 +48,31 @@ class IntegrationJWTAuthentication(authentication.BaseAuthentication): issuer=settings.INTEGRATIONS_JWT_ISSUER, ) except jwt.ExpiredSignatureError as e: - logger.error("Token expired") + logger.warning("Token expired") raise exceptions.AuthenticationFailed("Token expired.") from e except jwt.InvalidIssuerError as e: - logger.error("Invalid JWT issuer: %s", e) + logger.warning("Invalid JWT issuer: %s", e) raise exceptions.AuthenticationFailed("Invalid token.") from e except jwt.InvalidTokenError as e: - logger.error("Invalid JWT token: %s", e) + logger.warning("Invalid JWT token: %s", e) 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 + user_id = payload.get("user_id") + if not user_id: + logger.warning("Missing 'user_id' in JWT payload") + raise exceptions.AuthenticationFailed("Invalid token.") try: - user = User.objects.get(id=payload["user_id"]) + user = User.objects.get(id=user_id) except User.DoesNotExist as e: - logger.warning("User not found") - raise exceptions.AuthenticationFailed("User not found.") from e + logger.warning("User not found: %s", user_id) + raise exceptions.AuthenticationFailed("Invalid token.") from e if not user.is_active: - logger.warning("User inactive") - raise exceptions.AuthenticationFailed("User inactive.") + logger.warning("Inactive user attempted authentication: %s", user_id) + raise exceptions.AuthenticationFailed("Account disabled.") + # Attach token metadata to user user.token_scopes = payload.get("scope", []) user.is_impersonated = payload.get("impersonated", False) user.client_id = payload.get("client_id") diff --git a/src/backend/core/external_api/permissions.py b/src/backend/core/external_api/permissions.py index cb6c1465..6c50c97d 100644 --- a/src/backend/core/external_api/permissions.py +++ b/src/backend/core/external_api/permissions.py @@ -1,4 +1,4 @@ -"""Wip.""" +"""Permission handlers for the external API of the Meet core app.""" from rest_framework import exceptions, permissions from rest_framework_api_key.permissions import BaseHasAPIKey @@ -7,23 +7,26 @@ from ..models import ServiceAccountAPIKey, ServiceAccountScope class HasServiceAccountAPIKey(BaseHasAPIKey): - """Wip.""" + """Permission class that requires a valid service account API key. + + Validates that the request includes a valid API key in the Authorization header + using the format: "Api-Key ". The API key must belong to an active + ServiceAccount and the key itself must not be revoked. + + This permission is typically used for server-to-server authentication + where external services need to authenticate with our API.""" 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.""" + """Enforces scope-based access control on ViewSet actions. + + Provides fine-grained permission management by mapping ViewSet actions + to required scopes. JWT tokens must contain the appropriate scope to + perform specific operations, enabling precise control over what external + services can access (principle of least privilege). + """ # Map ViewSet actions to required scopes scope_map = { @@ -36,7 +39,7 @@ class HasRequiredScope(permissions.BasePermission): } def has_permission(self, request, view): - """Wip.""" + """Check if JWT token contains required scope for this action.""" action = getattr(view, "action", None) if not action: diff --git a/src/backend/core/external_api/serializers.py b/src/backend/core/external_api/serializers.py index b34e13c3..975997a1 100644 --- a/src/backend/core/external_api/serializers.py +++ b/src/backend/core/external_api/serializers.py @@ -1,6 +1,6 @@ -"""Wip.""" +"""Client serializers for the external API of the Meet core app.""" -# pylint: disable=abstract-method,no-name-in-module +# pylint: disable=abstract-method from django.conf import settings @@ -17,7 +17,17 @@ class JwtSerializer(BaseValidationOnlySerializer): class RoomSerializer(serializers.ModelSerializer): - """Serialize Room model for the API.""" + """External API serializer with limited, safe room information. + + Designed for external integrations with: + - Read-only access to most fields for security + - Additional computed fields (url, telephony info) + - Filtered data appropriate for external consumption + - Automatic room creation with secure defaults + + Intentionally limits exposed information compared to internal APIs, + providing only what external services need while protecting sensitive details. + """ class Meta: model = models.Room @@ -25,7 +35,7 @@ class RoomSerializer(serializers.ModelSerializer): read_only_fields = ["id", "name", "slug", "pin_code", "access_level"] def to_representation(self, instance): - """Wip.""" + """Add external-specific fields and filter sensitive data.""" output = super().to_representation(instance) request = self.context.get("request") @@ -46,10 +56,8 @@ class RoomSerializer(serializers.ModelSerializer): return output def create(self, validated_data): - """Custom create method.""" - + """Create room with secure defaults for external API.""" # todo - track source of creation - validated_data["name"] = utils.generate_slug() validated_data["access_level"] = models.RoomAccessLevel.TRUSTED diff --git a/src/backend/core/external_api/viewsets.py b/src/backend/core/external_api/viewsets.py index 925c2280..08f689d6 100644 --- a/src/backend/core/external_api/viewsets.py +++ b/src/backend/core/external_api/viewsets.py @@ -1,4 +1,4 @@ -"""Wip.""" +"""External API endpoints""" from datetime import datetime, timedelta from logging import getLogger @@ -17,18 +17,15 @@ from rest_framework import ( status as drf_status, ) -from core import models +from core import api, models -from . import permissions, serializers -from .authentication import IntegrationJWTAuthentication - -# pylint: disable=too-many-ancestors +from . import authentication, permissions, serializers logger = getLogger(__name__) -class IntegrationViewSet(viewsets.GenericViewSet): - """Wip.""" +class ServiceAccountViewSet(viewsets.GenericViewSet): + """Service account management for external integrations.""" permission_classes = [permissions.HasServiceAccountAPIKey] @@ -39,7 +36,14 @@ class IntegrationViewSet(viewsets.GenericViewSet): url_name="token", ) def generate_token(self, request, *args, **kwargs): - """Generate JWT token for a specific user identified by email.""" + """Generate JWT token for user impersonation. + + Token generation endpoint for service-to-service authentication. + + Exchanges service account API key for a scoped JWT token that can + impersonate a specific user. The service account must have permission + to impersonate the user's email domain. + """ serializer = serializers.JwtSerializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -81,7 +85,7 @@ class IntegrationViewSet(viewsets.GenericViewSet): "iss": settings.INTEGRATIONS_JWT_ISSUER, "impersonated": True, "client_id": str(service_account.id), # audience - "scope": service_account.scopes, + "scope": service_account.scopes or [], } try: @@ -112,10 +116,14 @@ class RoomViewSet( mixins.ListModelMixin, viewsets.GenericViewSet, ): - """Wip.""" + """External API for room management with scope-based permissions. - authentication_classes = [IntegrationJWTAuthentication] - permission_classes = [permissions.IsAuthenticated, permissions.HasRequiredScope] + Provides JWT-authenticated access to room operations for external services. + All operations are scoped and filtered to authenticated user's accessible rooms. + """ + + authentication_classes = [authentication.ServiceAccountJWTAuthentication] + permission_classes = [api.permissions.IsAuthenticated, permissions.HasRequiredScope] queryset = models.Room.objects.all() serializer_class = serializers.RoomSerializer diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index 34b97e63..69e166ce 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -23,9 +23,9 @@ external_router.register( "rooms", external_viewsets.RoomViewSet, basename="external_rooms" ) external_router.register( - "integrations", - external_viewsets.IntegrationViewSet, - basename="external_integrations", + "service-account", + external_viewsets.ServiceAccountViewSet, + basename="external_service_account", ) urlpatterns = [ diff --git a/src/backend/core/utils.py b/src/backend/core/utils.py index dfac8d82..751d9270 100644 --- a/src/backend/core/utils.py +++ b/src/backend/core/utils.py @@ -30,7 +30,7 @@ from livekit.api import ( # pylint: disable=E0611 def generate_slug(): - """Wip.""" + """Generate a random room slug in the format 'xxx-xxxx-xxx'.""" sizes = [3, 4, 3] parts = [