This commit is contained in:
lebaudantoine
2025-10-02 18:28:35 +02:00
parent ac720d7ad4
commit 3c52fa22f5
8 changed files with 83 additions and 63 deletions
+5 -5
View File
@@ -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"]
@@ -0,0 +1 @@
"""Meet core external API endpoints"""
+20 -20
View File
@@ -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")
+17 -14
View File
@@ -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 <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:
+15 -7
View File
@@ -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
+21 -13
View File
@@ -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
+3 -3
View File
@@ -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 = [
+1 -1
View File
@@ -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 = [