wip
This commit is contained in:
@@ -65,3 +65,5 @@ ROOM_TELEPHONY_ENABLED=True
|
||||
|
||||
FRONTEND_USE_FRENCH_GOV_FOOTER=False
|
||||
FRONTEND_USE_PROCONNECT_BUTTON=False
|
||||
|
||||
INTEGRATIONS_JWT_SECRET_KEY=devKey
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user