Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 79c43a5479 | |||
| 5f9b149db3 | |||
| 018d15d9be | |||
| ab6121097c | |||
| db8a22dad3 | |||
| 12743f6469 | |||
| c91a32a556 | |||
| 3c52fa22f5 | |||
| ac720d7ad4 | |||
| fe967bdb2d | |||
| b64e5a0084 | |||
| 5f90e580e3 | |||
| 2b884e410c | |||
| a0636ba02a | |||
| 30eb3e1fa7 | |||
| c83d3b99fc | |||
| a58d3416e0 | |||
| c3eb877377 |
@@ -30,17 +30,17 @@ jobs:
|
||||
images: lasuite/meet-backend
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
# if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
docker-build-args: '--target backend-production -f Dockerfile'
|
||||
docker-image-name: 'docker.io/lasuite/meet-backend:${{ github.sha }}'
|
||||
# -
|
||||
# name: Run trivy scan
|
||||
# uses: numerique-gouv/action-trivy-cache@main
|
||||
# with:
|
||||
# docker-build-args: '--target backend-production -f Dockerfile'
|
||||
# docker-image-name: 'docker.io/lasuite/meet-backend:${{ github.sha }}'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
context: .
|
||||
target: backend-production
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
push: ${{ github.event_name == 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
|
||||
@@ -62,6 +62,11 @@ SCREEN_RECORDING_BASE_URL=http://localhost:3000/recordings
|
||||
|
||||
# Telephony
|
||||
ROOM_TELEPHONY_ENABLED=True
|
||||
ROOM_TELEPHONY_DEFAULT_COUNTRY: 'FR'
|
||||
ROOM_TELEPHONY_PHONE_NUMBER: '+33901020304'
|
||||
|
||||
FRONTEND_USE_FRENCH_GOV_FOOTER=False
|
||||
FRONTEND_USE_PROCONNECT_BUTTON=False
|
||||
|
||||
INTEGRATIONS_JWT_SECRET_KEY=devKey
|
||||
INTEGRATIONS_APP_BASE_URL: http://localhost:3000
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"""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 _
|
||||
|
||||
from rest_framework_api_key.admin import APIKeyModelAdmin
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
@@ -150,3 +153,53 @@ class RecordingAdmin(admin.ModelAdmin):
|
||||
return _("Multiple owners")
|
||||
|
||||
return str(owners[0].user)
|
||||
|
||||
|
||||
class ServiceAccountDomainInline(admin.TabularInline):
|
||||
"""Inline admin for managing allowed domains per service account."""
|
||||
|
||||
model = models.ServiceAccountDomain
|
||||
extra = 0
|
||||
|
||||
|
||||
class ServiceAccountAdminForm(forms.ModelForm):
|
||||
"""Custom form for ServiceAccount admin with multi-select scopes."""
|
||||
|
||||
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):
|
||||
"""Admin interface for managing service accounts and their permissions."""
|
||||
|
||||
form = ServiceAccountAdminForm
|
||||
|
||||
list_display = ("id", "name", "client_id", "get_scopes_display")
|
||||
fields = ["name", "id", "created_at", "updated_at", "scopes", "client_id"]
|
||||
readonly_fields = ["id", "created_at", "updated_at", "client_id"]
|
||||
inlines = [ServiceAccountDomainInline]
|
||||
|
||||
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.ServiceAccountSecret)
|
||||
class ServiceAccountSecretModelAdmin(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"""
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Authentication Backends for service accounts integrated to the Meet core app."""
|
||||
|
||||
import logging
|
||||
|
||||
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()
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ServiceAccountJWTAuthentication(authentication.BaseAuthentication):
|
||||
"""JWT authentication for external API endpoints."""
|
||||
|
||||
def authenticate(self, request):
|
||||
"""Extract and validate JWT from Authorization header."""
|
||||
|
||||
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:
|
||||
logger.warning("Invalid token header")
|
||||
raise exceptions.AuthenticationFailed("Invalid token header.")
|
||||
|
||||
try:
|
||||
token = auth_header[1].decode("utf-8")
|
||||
except UnicodeError as e:
|
||||
logger.warning("Invalid: %s", e)
|
||||
raise exceptions.AuthenticationFailed("Invalid token.") from e
|
||||
|
||||
return self.authenticate_credentials(token)
|
||||
|
||||
def authenticate_credentials(self, token):
|
||||
"""Authenticate and validate JWT token credentials."""
|
||||
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.INTEGRATIONS_JWT_SECRET_KEY,
|
||||
algorithms=[settings.INTEGRATIONS_JWT_ALG],
|
||||
issuer=settings.INTEGRATIONS_JWT_ISSUER,
|
||||
audience=settings.INTEGRATIONS_JWT_AUDIENCE,
|
||||
)
|
||||
except jwt.ExpiredSignatureError as e:
|
||||
logger.warning("Token expired")
|
||||
raise exceptions.AuthenticationFailed("Token expired.") from e
|
||||
except jwt.InvalidIssuerError as e:
|
||||
logger.warning("Invalid JWT issuer: %s", e)
|
||||
raise exceptions.AuthenticationFailed("Invalid token.") from e
|
||||
except jwt.InvalidTokenError as e:
|
||||
logger.warning("Invalid JWT token: %s", e)
|
||||
raise exceptions.AuthenticationFailed("Invalid token.") from e
|
||||
|
||||
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=user_id)
|
||||
except User.DoesNotExist as e:
|
||||
logger.warning("User not found: %s", user_id)
|
||||
raise exceptions.AuthenticationFailed("Invalid token.") from e
|
||||
|
||||
if not user.is_active:
|
||||
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")
|
||||
|
||||
return (user, token)
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Permission handlers for the external API of the Meet core app."""
|
||||
|
||||
from rest_framework import exceptions, permissions
|
||||
|
||||
from .. import models
|
||||
|
||||
|
||||
class HasRequiredScope(permissions.BasePermission):
|
||||
"""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).
|
||||
"""
|
||||
|
||||
scope_map = {
|
||||
"list": models.ServiceAccountScope.ROOMS_LIST,
|
||||
"retrieve": models.ServiceAccountScope.ROOMS_RETRIEVE,
|
||||
"create": models.ServiceAccountScope.ROOMS_CREATE,
|
||||
"update": models.ServiceAccountScope.ROOMS_UPDATE,
|
||||
"partial_update": models.ServiceAccountScope.ROOMS_UPDATE,
|
||||
"destroy": models.ServiceAccountScope.ROOMS_DELETE,
|
||||
}
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if JWT token contains required scope for this action."""
|
||||
|
||||
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", [])
|
||||
|
||||
# todo - parse properly scope
|
||||
|
||||
if required_scope not in token_scopes:
|
||||
raise exceptions.PermissionDenied("Insufficient permissions")
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Client serializers for the external API of the Meet core app."""
|
||||
|
||||
# pylint: disable=abstract-method
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from core import models, utils
|
||||
from core.api.serializers import BaseValidationOnlySerializer
|
||||
|
||||
|
||||
class JwtSerializer(BaseValidationOnlySerializer):
|
||||
"""Validate OAuth2 JWT token request data."""
|
||||
|
||||
client_id = serializers.UUIDField()
|
||||
client_secret = serializers.CharField(write_only=True)
|
||||
grant_type = serializers.ChoiceField(choices=["client_credentials"])
|
||||
scope = serializers.EmailField()
|
||||
|
||||
|
||||
class RoomSerializer(serializers.ModelSerializer):
|
||||
"""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
|
||||
fields = ["id", "name", "slug", "pin_code", "access_level"]
|
||||
read_only_fields = ["id", "name", "slug", "pin_code", "access_level"]
|
||||
|
||||
def to_representation(self, instance):
|
||||
"""Add external-specific fields and filter sensitive data."""
|
||||
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"]
|
||||
del output["name"]
|
||||
|
||||
return output
|
||||
|
||||
def create(self, validated_data):
|
||||
"""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
|
||||
|
||||
return super().create(validated_data)
|
||||
@@ -0,0 +1,186 @@
|
||||
"""External API endpoints"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import jwt
|
||||
from rest_framework import decorators, mixins, 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 api, models
|
||||
|
||||
from . import authentication, permissions, serializers
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class ServiceAccountViewSet(viewsets.GenericViewSet):
|
||||
"""Service account management for external integrations."""
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["post"],
|
||||
url_path="token",
|
||||
url_name="token",
|
||||
)
|
||||
def generate_token(self, request, *args, **kwargs):
|
||||
"""Generate JWT token for user impersonation.
|
||||
|
||||
Token generation endpoint for service-to-service authentication.
|
||||
|
||||
Exchanges service account client_id / client_secret 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)
|
||||
|
||||
client_id = serializer.validated_data["client_id"]
|
||||
client_secret = serializer.validated_data["client_secret"]
|
||||
|
||||
try:
|
||||
service_account = models.ServiceAccount.objects.get(client_id=client_id)
|
||||
except models.ServiceAccount.DoesNotExist as e:
|
||||
raise drf_exceptions.AuthenticationFailed("Invalid client_id") from e
|
||||
|
||||
try:
|
||||
service_account_secret = models.ServiceAccountSecret.objects.get_from_key(
|
||||
client_secret
|
||||
)
|
||||
except models.ServiceAccountSecret.DoesNotExist as e:
|
||||
raise drf_exceptions.AuthenticationFailed("Invalid client_secret") from e
|
||||
|
||||
if (
|
||||
service_account.client_id
|
||||
!= service_account_secret.service_account.client_id
|
||||
):
|
||||
raise drf_exceptions.AuthenticationFailed("Invalid client_secret")
|
||||
|
||||
# todo - explain why scope is an email here
|
||||
email = serializer.validated_data["scope"]
|
||||
|
||||
if not service_account.can_impersonate_email(email):
|
||||
logger.warning(
|
||||
"Service account %s denied impersonation of %s",
|
||||
service_account.id,
|
||||
email,
|
||||
)
|
||||
return drf_response.Response(
|
||||
{
|
||||
"error": "Access denied",
|
||||
"detail": "Cannot impersonate this user. The user's domain may not be authorized in the domain delegation settings for this service account.",
|
||||
},
|
||||
status=drf_status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
try:
|
||||
user = models.User.objects.get(email=email)
|
||||
except models.User.DoesNotExist as e:
|
||||
# todo - create unknown user
|
||||
raise drf_exceptions.NotFound(
|
||||
{
|
||||
"error": "User not found",
|
||||
"detail": f"No user with email '{email}' exists.",
|
||||
}
|
||||
) from e
|
||||
|
||||
now = datetime.utcnow()
|
||||
scopes = service_account.scopes or []
|
||||
payload = {
|
||||
# Standard JWT claims
|
||||
"iss": settings.INTEGRATIONS_JWT_ISSUER,
|
||||
"aud": settings.INTEGRATIONS_JWT_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now
|
||||
+ timedelta(seconds=settings.INTEGRATIONS_JWT_EXPIRATION_SECONDS),
|
||||
# Application claims
|
||||
"client_id": str(client_id),
|
||||
"scope": " ".join(scopes),
|
||||
# Minimal user info
|
||||
"user_id": str(user.id),
|
||||
"impersonated": True,
|
||||
}
|
||||
|
||||
try:
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
settings.INTEGRATIONS_JWT_SECRET_KEY,
|
||||
algorithm=settings.INTEGRATIONS_JWT_ALG,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("Unexpected error during JWT token generation")
|
||||
return drf_response.Response(
|
||||
{
|
||||
"error": "Internal server error",
|
||||
"detail": "An unexpected error occurred during JWT token generation. Please contact support.",
|
||||
},
|
||||
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
return drf_response.Response(
|
||||
{
|
||||
"access_token": token,
|
||||
"token_type": settings.INTEGRATIONS_JWT_TOKEN_TYPE,
|
||||
"expires_in": settings.INTEGRATIONS_JWT_EXPIRATION_SECONDS,
|
||||
"scope": " ".join(scopes),
|
||||
},
|
||||
status=drf_status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
class RoomViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""External API for room management with scope-based permissions.
|
||||
|
||||
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
|
||||
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,72 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-02 13:15
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0014_room_pin_code'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ServiceAccount',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('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)),
|
||||
('client_id', models.UUIDField(default=uuid.uuid4, help_text='Unique public identifier for the client, auto-generated UUID used for external integration', verbose_name='client_id')),
|
||||
('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), blank=True, default=list, null=True, size=None)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Service account',
|
||||
'verbose_name_plural': 'Service accounts',
|
||||
'db_table': 'meet_service_account',
|
||||
'ordering': ('-created_at',),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ServiceAccountSecret',
|
||||
fields=[
|
||||
('id', models.CharField(editable=False, max_length=150, primary_key=True, serialize=False, unique=True)),
|
||||
('prefix', models.CharField(editable=False, max_length=8, unique=True)),
|
||||
('hashed_key', models.CharField(editable=False, max_length=150)),
|
||||
('created', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('name', models.CharField(default=None, help_text='A free-form name for the API key. Need not be unique. 50 characters max.', max_length=50)),
|
||||
('revoked', models.BooleanField(blank=True, default=False, help_text='If the API key is revoked, clients cannot use it anymore. (This cannot be undone.)')),
|
||||
('expiry_date', models.DateTimeField(blank=True, help_text='Once API key expires, clients cannot use it anymore.', null=True, verbose_name='Expires')),
|
||||
('service_account', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='client_secret', to='core.serviceaccount')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'API key',
|
||||
'verbose_name_plural': 'API keys',
|
||||
'ordering': ('-created',),
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ServiceAccountDomain',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('domain', models.CharField(help_text='Email domain that can be impersonated.', max_length=253, validators=[django.core.validators.DomainNameValidator(accept_idna=False, message='Enter a valid domain')], verbose_name='Domain')),
|
||||
('service_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='allowed_domains', to='core.serviceaccount')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Service account domain',
|
||||
'verbose_name_plural': 'Service account domains',
|
||||
'db_table': 'meet_service_account_domain',
|
||||
'ordering': ('domain',),
|
||||
'unique_together': {('service_account', 'domain')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
@@ -18,6 +19,7 @@ from django.utils import timezone
|
||||
from django.utils.text import capfirst, slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from rest_framework_api_key.models import AbstractAPIKey
|
||||
from timezone_field import TimeZoneField
|
||||
|
||||
from .recording.enums import FileExtension
|
||||
@@ -717,3 +719,103 @@ class RecordingAccess(BaseAccess):
|
||||
Compute and return abilities for a given user on the recording access.
|
||||
"""
|
||||
return self._get_abilities(self.recording, user)
|
||||
|
||||
|
||||
class ServiceAccountScope(models.TextChoices):
|
||||
"""Available permission scopes for service account operations."""
|
||||
|
||||
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):
|
||||
"""Service account for external API authentication and authorization."""
|
||||
|
||||
name = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_("Service account name"),
|
||||
help_text=_("Descriptive name for this service account."),
|
||||
)
|
||||
active = models.BooleanField(default=True)
|
||||
client_id = models.UUIDField(
|
||||
verbose_name=_("client_id"),
|
||||
help_text=_(
|
||||
"Unique public identifier for the client, auto-generated UUID used for external integration"
|
||||
),
|
||||
default=uuid.uuid4,
|
||||
)
|
||||
|
||||
scopes = ArrayField(
|
||||
models.CharField(max_length=50, choices=ServiceAccountScope.choices),
|
||||
default=list,
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "meet_service_account"
|
||||
ordering = ("-created_at",)
|
||||
verbose_name = _("Service account")
|
||||
verbose_name_plural = _("Service accounts")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name!s}"
|
||||
|
||||
def can_impersonate_email(self, email):
|
||||
"""Check if this service account can impersonate the given email."""
|
||||
|
||||
if not self.allowed_domains.exists():
|
||||
return True # No domain restrictions
|
||||
|
||||
domain = email.split("@")[-1]
|
||||
return self.allowed_domains.filter(domain__iexact=domain).exists()
|
||||
|
||||
|
||||
class ServiceAccountDomain(BaseModel):
|
||||
"""Domain allowed for service account impersonation."""
|
||||
|
||||
domain = models.CharField(
|
||||
max_length=253, # Max domain length per RFC
|
||||
validators=[
|
||||
validators.DomainNameValidator(
|
||||
accept_idna=False,
|
||||
message=_("Enter a valid domain"),
|
||||
)
|
||||
],
|
||||
verbose_name=_("Domain"),
|
||||
help_text=_("Email domain that can be impersonated."),
|
||||
)
|
||||
|
||||
service_account = models.ForeignKey(
|
||||
"ServiceAccount",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="allowed_domains",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "meet_service_account_domain"
|
||||
ordering = ("domain",)
|
||||
verbose_name = _("Service account domain")
|
||||
verbose_name_plural = _("Service account domains")
|
||||
unique_together = [("service_account", "domain")]
|
||||
|
||||
def __str__(self):
|
||||
return self.domain
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Normalize domain to lowercase
|
||||
self.domain = self.domain.lower().strip()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class ServiceAccountSecret(AbstractAPIKey):
|
||||
"""API key for service account authentication."""
|
||||
|
||||
service_account = models.OneToOneField(
|
||||
ServiceAccount,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="client_secret",
|
||||
)
|
||||
|
||||
@@ -131,8 +131,8 @@ class NotificationService:
|
||||
if not owner_access:
|
||||
logger.error("No owner found for recording %s", recording.id)
|
||||
return False
|
||||
|
||||
payload = {
|
||||
"owner_id": str(owner_access.user.id),
|
||||
"filename": recording.key,
|
||||
"email": owner_access.user.email,
|
||||
"sub": owner_access.user.sub,
|
||||
|
||||
@@ -7,6 +7,7 @@ from lasuite.oidc_login.urls import urlpatterns as oidc_urls
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from core.api import get_frontend_configuration, viewsets
|
||||
from core.external_api import viewsets as external_viewsets
|
||||
|
||||
# - Main endpoints
|
||||
router = DefaultRouter()
|
||||
@@ -17,6 +18,16 @@ 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(
|
||||
"service-account",
|
||||
external_viewsets.ServiceAccountViewSet,
|
||||
basename="external_service_account",
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
f"api/{settings.API_VERSION}/",
|
||||
@@ -28,4 +39,12 @@ urlpatterns = [
|
||||
]
|
||||
),
|
||||
),
|
||||
path(
|
||||
f"external-api/{settings.EXTERNAL_API_VERSION}/",
|
||||
include(
|
||||
[
|
||||
*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,17 @@ from livekit.api import ( # pylint: disable=E0611
|
||||
)
|
||||
|
||||
|
||||
def generate_slug():
|
||||
"""Generate a random room slug in the format 'xxx-xxxx-xxx'."""
|
||||
|
||||
sizes = [3, 4, 3]
|
||||
parts = [
|
||||
"".join(secrets.choice(string.ascii_lowercase) for _ in range(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.
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -226,6 +227,7 @@ class Base(Configuration):
|
||||
"corsheaders",
|
||||
"dockerflow.django",
|
||||
"rest_framework",
|
||||
"rest_framework_api_key",
|
||||
"parler",
|
||||
"easy_thumbnails",
|
||||
# Django
|
||||
@@ -664,6 +666,41 @@ 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_AUDIENCE = values.Value(
|
||||
"http://localhost:8071/external-api/v1.0/",
|
||||
environ_name="INTEGRATIONS_JWT_AUDIENCE",
|
||||
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):
|
||||
|
||||
@@ -40,6 +40,7 @@ dependencies = [
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.2.6",
|
||||
"djangorestframework==3.16.0",
|
||||
"djangorestframework-api-key==3.1.0",
|
||||
"drf_spectacular==0.28.0",
|
||||
"dockerflow==2024.4.2",
|
||||
"easy_thumbnails==2.10",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
apiVersion: v2
|
||||
type: application
|
||||
name: meet
|
||||
version: 0.0.12
|
||||
version: 0.0.11-debug
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -18,6 +18,7 @@ settings = get_settings()
|
||||
class TaskCreation(BaseModel):
|
||||
"""Task data."""
|
||||
|
||||
owner_id: str
|
||||
filename: str
|
||||
email: str
|
||||
sub: str
|
||||
@@ -35,6 +36,7 @@ async def create_task(request: TaskCreation):
|
||||
"""Create a task."""
|
||||
task = process_audio_transcribe_summarize_v2.apply_async(
|
||||
args=[
|
||||
request.owner_id,
|
||||
request.filename,
|
||||
request.email,
|
||||
request.sub,
|
||||
|
||||
@@ -118,9 +118,9 @@ class MetadataManager:
|
||||
"retries": 0,
|
||||
}
|
||||
|
||||
_required_args_count = 7
|
||||
_required_args_count = 8
|
||||
if len(task_args) != _required_args_count:
|
||||
logger.error("Invalid number of arguments.")
|
||||
logger.error("Invalid number of arguments to enable metadata manager.")
|
||||
return
|
||||
|
||||
filename, email, _, received_at, *_ = task_args
|
||||
|
||||
@@ -204,6 +204,7 @@ def task_failure_handler(task_id, exception=None, **kwargs):
|
||||
)
|
||||
def process_audio_transcribe_summarize_v2(
|
||||
self,
|
||||
owner_id: str,
|
||||
filename: str,
|
||||
email: str,
|
||||
sub: str,
|
||||
@@ -320,7 +321,7 @@ def process_audio_transcribe_summarize_v2(
|
||||
metadata_manager.capture(task_id, settings.posthog_event_success)
|
||||
|
||||
if (
|
||||
analytics.is_feature_enabled("summary-enabled", distinct_id=sub)
|
||||
analytics.is_feature_enabled("summary-enabled", distinct_id=owner_id)
|
||||
and settings.is_summary_enabled
|
||||
):
|
||||
logger.info("Queuing summary generation task.")
|
||||
|
||||
Reference in New Issue
Block a user