Compare commits

...

5 Commits

Author SHA1 Message Date
leo b0d421f055 ✨(backend) add Menshen token-exchange authentication to external API
Menshen is La Suite's OAuth 2.0 token exchange server (RFC 8693): another service
exchanges its user's access token for a token targeting Meet, then calls the external
API on behalf of that user. Tokens are validated by introspection (RFC 7662). Menshen
only reports a token as active when Meet is among its audiences.

Add Menshen auth to the external API. Only activate it for room creation scope (other
scopes may be added later). If a user doesn't exist for the provided sub,
create it, unless the provided email is already associated to a an existing account.
2026-10-13 18:18:45 +02:00
lebaudantoine d930fa1ba6 ⚡️(backend) add UPPER(email) index for case-insensitive user lookups
Case-insensitive user lookups during token issuance currently take
around 100 ms in production, accounting for roughly a third of the
total 300 ms token request trace.

Add an `UPPER(email)` index on the user table so these lookups can
use the index instead of scanning, cutting the dominant cost on
the token issuance path.
2026-10-13 15:20:04 +02:00
lebaudantoine 6f1414b34e 🔖(minor) bump release to 1.35.0 2026-10-13 09:34:46 +02:00
lebaudantoine 0194f7f3f3 ⚡️(backend) send recording notification emails from a Celery task
Emails were rendered and sent inline in the LiveKit webhook, so a
slow SMTP server blocked the request and a transient failure lost
the email. Delivery now runs in a worker, with retries and backoff.

The task is generic (template, subject, recipients, context,
language) so other emails can reuse it. The subject is translated
in the worker, and dates are formatted per owner before queuing,
so the arguments stay JSON-serializable.

Without Celery, the task still runs synchronously. Success now
means "queued", not "delivered".
2026-10-11 23:37:00 +02:00
Roee Sivan 0bde103911 🔧(project) silence gitlint regex-style deprecation warning
gitlint prints a warning for each ignore-by-title rule because it will switch
its regexes from match (start of the string) to search (anywhere) semantics.

Both ignore-by-title regexes are already anchored with ^, so they match the
same titles either way. Setting regex-style-search opts in to the new behavior
now and removes the warning.

Fixes #96
2026-10-11 23:34:02 +02:00
32 changed files with 891 additions and 105 deletions
+3
View File
@@ -22,6 +22,9 @@
# See http://jorisroovers.github.io/gitlint/user_defined_rules for details # See http://jorisroovers.github.io/gitlint/user_defined_rules for details
extra-path=gitlint/ extra-path=gitlint/
# Use "search" semantics for regexes (gitlint's future default)
regex-style-search=True
# [title-max-length] # [title-max-length]
# line-length=80 # line-length=80
+8
View File
@@ -10,8 +10,16 @@ and this project adheres to
### Added ### Added
- ⚡️(backend) add UPPER(email) index for case-insensitive user lookups
- ✨(backend) authenticate external API calls with Menshen exchanged tokens
## [1.35.0] - 2026-10-13
### Added
- ✨(frontend) let users pick the recording encoding - ✨(frontend) let users pick the recording encoding
- ✨(backend) introduce a token exchange endpoint for iframe embeds - ✨(backend) introduce a token exchange endpoint for iframe embeds
- ⚡️(backend) send recording notification emails from a Celery task
### Changed ### Changed
+1
View File
@@ -113,6 +113,7 @@ DJANGO_EMAIL_FROM=<your email address>
#DJANGO_EMAIL_USE_TLS=true # A flag to enable or disable TLS for email sending. #DJANGO_EMAIL_USE_TLS=true # A flag to enable or disable TLS for email sending.
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending. #DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
#DJANGO_EMAIL_TIMEOUT=30 # Seconds before a stalled SMTP connection times out.
DJANGO_EMAIL_BRAND_NAME=<brand name used in email templates> # e.g. "La Suite Numérique" DJANGO_EMAIL_BRAND_NAME=<brand name used in email templates> # e.g. "La Suite Numérique"
+1
View File
@@ -357,6 +357,7 @@ These are the environmental options available on meet backend.
| DJANGO_EMAIL_PORT | Port to connect to the email server | | | DJANGO_EMAIL_PORT | Port to connect to the email server | |
| DJANGO_EMAIL_USE_TLS | Enable TLS on email connection | false | | DJANGO_EMAIL_USE_TLS | Enable TLS on email connection | false |
| DJANGO_EMAIL_USE_SSL | Enable SSL on email connection | false | | DJANGO_EMAIL_USE_SSL | Enable SSL on email connection | false |
| DJANGO_EMAIL_TIMEOUT | Seconds before a stalled SMTP connection times out | 30 |
| DJANGO_EMAIL_FROM | Email from account | from@example.com | | DJANGO_EMAIL_FROM | Email from account | from@example.com |
| EMAIL_BRAND_NAME | Email branding name | | | EMAIL_BRAND_NAME | Email branding name | |
| EMAIL_SUPPORT_EMAIL | Support email address | | | EMAIL_SUPPORT_EMAIL | Support email address | |
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "agents" name = "agents"
version = "1.34.0" version = "1.35.0"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"livekit-agents==1.7.0", "livekit-agents==1.7.0",
+1 -1
View File
@@ -9,7 +9,7 @@ resolution-markers = [
[[package]] [[package]]
name = "agents" name = "agents"
version = "1.34.0" version = "1.35.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "boto3" }, { name = "boto3" },
@@ -82,9 +82,6 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
"User info contained an invalid sub claim" "User info contained an invalid sub claim"
) from err ) from err
if len(sub) > 255:
raise SuspiciousOperation("User info contained an invalid sub claim")
try: try:
return User.objects.get(sub=sub) return User.objects.get(sub=sub)
except User.DoesNotExist: except User.DoesNotExist:
+212 -42
View File
@@ -4,21 +4,107 @@
# ruff: noqa: PLR0913 # ruff: noqa: PLR0913
import logging import logging
from dataclasses import asdict
from functools import lru_cache
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.exceptions import SuspiciousOperation from django.core.exceptions import SuspiciousOperation, ValidationError
import requests
from lasuite.oidc_resource_server.backend import ResourceServerBackend as LaSuiteBackend from lasuite.oidc_resource_server.backend import ResourceServerBackend as LaSuiteBackend
from menshen_client import Configuration, IntrospectionRequest, TokenExchangeClient
from menshen_client.exceptions import ResponseParsingError
from rest_framework import authentication, exceptions from rest_framework import authentication, exceptions
from core.models import Application from core.models import Application, ApplicationScope
from core.services import jwt_token from core.services import jwt_token
from core.validators import sub_validator
User = get_user_model() User = get_user_model()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def get_token(request, token_type):
"""Extract the token from the Authorization header.
Args:
request: The incoming request
token_type: Expected Authorization scheme (e.g. Bearer), matched
case-insensitively
Returns:
Token string, or None if the request carries no token of this type
Raises:
AuthenticationFailed: If the Authorization header is malformed
"""
auth_header = authentication.get_authorization_header(request).split()
if not auth_header or auth_header[0].lower() != token_type.lower().encode():
return None
if len(auth_header) != 2:
logger.warning("Invalid token header format")
raise exceptions.AuthenticationFailed("Invalid token header.")
try:
return auth_header[1].decode("utf-8")
except UnicodeError as e:
logger.warning("Token decode error: %s", e)
raise exceptions.AuthenticationFailed("Invalid token encoding.") from e
def get_or_create_user_by_sub(sub, email=None):
"""Retrieve the user matching a token subject, creating it if allowed.
Shared by the resource server backends, so that tokens introspected with the
OIDC provider or with Menshen resolve users the same way.
Args:
sub: Subject identifier from the introspected token
email: Email to store on a newly created user
Returns:
User instance, or None if unknown and OIDC_CREATE_USER is unset
Raises:
SuspiciousOperation: If the sub is invalid, the email of a user to create
is already used, or the user account is disabled
"""
try:
sub_validator(sub)
except ValidationError as err:
raise SuspiciousOperation("User info contained an invalid sub claim") from err
try:
user = User.objects.get(sub=sub)
except User.DoesNotExist:
if not settings.OIDC_CREATE_USER:
return None
# Users are never identified by email here. But a second account with the
# same email (e.g. provisionnal account) would break the lookups by email:
# refuse it.
if email and User.objects.filter(email__iexact=email).exists():
logger.warning("Email already used, not creating user: %s", sub)
raise SuspiciousOperation(
"Email already used by another account."
) from None
user = User(sub=sub, email=email)
user.set_unusable_password()
user.save()
if not user.is_active:
logger.warning("Inactive user attempted authentication: %s", user.pk)
raise SuspiciousOperation("User account is disabled.")
return user
class BaseJWTAuthentication(authentication.BaseAuthentication): class BaseJWTAuthentication(authentication.BaseAuthentication):
"""Base JWT authentication class.""" """Base JWT authentication class."""
@@ -72,25 +158,12 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
if not self.is_enabled: if not self.is_enabled:
return None return None
auth_header = authentication.get_authorization_header(request).split() token = get_token(request, self._token_type)
if ( if token is None:
not auth_header
or auth_header[0].lower() != self._token_type.lower().encode()
):
# Defer to next authentication backend # Defer to next authentication backend
return None return None
if len(auth_header) != 2:
logger.warning("Invalid token header format")
raise exceptions.AuthenticationFailed("Invalid token header.")
try:
token = auth_header[1].decode("utf-8")
except UnicodeError as e:
logger.warning("Token decode error: %s", e)
raise exceptions.AuthenticationFailed("Invalid token encoding.") from e
return self.authenticate_credentials(token) return self.authenticate_credentials(token)
def decode_jwt(self, token): def decode_jwt(self, token):
@@ -256,6 +329,127 @@ class AddonsJWTAuthentication(BaseJWTAuthentication):
) )
# Menshen grants scopes following La Suite's "service:resource:action" convention.
# Map them to the scopes expected by the external API permissions.
MENSHEN_SCOPES_MAPPING = {
"meet:room:create": ApplicationScope.ROOMS_CREATE,
}
@lru_cache(1)
def _get_token_exchange_client(client_id, client_secret, server_url):
return TokenExchangeClient(
config=Configuration(
client_id,
client_secret,
server_url,
)
)
class MenshenAuthentication(authentication.BaseAuthentication):
"""Authentication for tokens exchanged through Menshen.
Menshen is La Suite's OAuth 2.0 token exchange server (RFC 8693): another service
exchanges its user's access token for a token targeting Meet, then calls the external
API on behalf of that user. Tokens are validated by introspection (RFC 7662). Menshen
only reports a token as active when Meet is among its audiences.
"""
def __init__(self):
"""Initialize the Menshen client from Django settings."""
super().__init__()
self._client = None
if not settings.MENSHEN_ENABLED:
return
self._client = _get_token_exchange_client(
settings.MENSHEN_CLIENT_ID,
settings.MENSHEN_CLIENT_SECRET,
settings.MENSHEN_SERVER_URL,
)
def authenticate(self, request):
"""Introspect the bearer token with Menshen.
Returns:
Tuple of (user, payload) if the token is active, None otherwise
"""
if self._client is None:
return None
token = get_token(request, "bearer")
if token is None:
return None
try:
response = self._client.introspect(IntrospectionRequest(token=token))
except (requests.RequestException, ResponseParsingError) as e:
logger.warning("Menshen introspection failed: %s", e)
return None
# Permission classes expect a dict payload in request.auth
payload = asdict(response)
if not payload.get("active"):
# Not a Menshen token, or an expired or revoked one: defer to next
# authentication backend
return None
user = self.get_or_create_user(payload)
scopes = payload.get("scope") or ""
payload["scope"] = [
MENSHEN_SCOPES_MAPPING[scope]
for scope in scopes.split()
if scope in MENSHEN_SCOPES_MAPPING
]
return (user, payload)
def get_or_create_user(self, payload):
"""Retrieve or create the user from the introspection response.
Menshen forwards the `sub` and `email` of the subject token, as introspected
with the OIDC provider.
Args:
payload: Introspection response dict
Returns:
User instance
Raises:
AuthenticationFailed: If user not found or inactive
"""
sub = payload.get("sub")
if not sub:
logger.warning("Missing 'sub' in Menshen introspection response")
raise exceptions.AuthenticationFailed("Invalid token claims.")
try:
user = get_or_create_user_by_sub(sub, email=payload.get("email"))
except SuspiciousOperation as e:
raise exceptions.AuthenticationFailed(str(e)) from e
if user is None:
logger.warning("User not found: %s", sub)
raise exceptions.AuthenticationFailed("User not found.")
return user
def authenticate_header(self, request):
"""Return authentication scheme for WWW-Authenticate header."""
return "bearer"
class ResourceServerBackend(LaSuiteBackend): class ResourceServerBackend(LaSuiteBackend):
"""OIDC Resource Server backend for user creation and retrieval.""" """OIDC Resource Server backend for user creation and retrieval."""
@@ -285,28 +479,4 @@ class ResourceServerBackend(LaSuiteBackend):
logger.debug(message) logger.debug(message)
raise SuspiciousOperation(message) raise SuspiciousOperation(message)
user = self.get_user(access_token, id_token, payload) return get_or_create_user_by_sub(sub)
if user is None and settings.OIDC_CREATE_USER:
user = self.create_user(sub)
if user is not None and not user.is_active:
logger.warning("Inactive user attempted authentication: %s", user.pk)
raise SuspiciousOperation("User account is disabled.")
return user
def create_user(self, sub):
"""Create new user from subject claim.
Args:
sub: Subject identifier from token
Returns:
Newly created User instance
"""
user = self.UserModel(sub=sub)
user.set_unusable_password()
user.save()
return user
@@ -166,6 +166,7 @@ class RoomViewSet(
authentication_classes = [ authentication_classes = [
authentication.ApplicationJWTAuthentication, authentication.ApplicationJWTAuthentication,
authentication.AddonsJWTAuthentication, authentication.AddonsJWTAuthentication,
authentication.MenshenAuthentication,
ResourceServerAuthentication, ResourceServerAuthentication,
] ]
permission_classes = [ permission_classes = [
@@ -0,0 +1,23 @@
# Generated by Django 5.2.14 on 2026-10-07 18:45
from django.contrib.postgres.operations import AddIndexConcurrently
from django.db import migrations, models
from django.db.models.functions import Upper
class Migration(migrations.Migration):
atomic = False
dependencies = [
('core', '0026_alter_application_scopes'),
]
operations = [
AddIndexConcurrently(
model_name="user",
index=models.Index(
Upper("email"),
name="user_email_upper_idx",
),
),
]
+3
View File
@@ -246,6 +246,9 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
name="unique_email_when_sub_is_null", name="unique_email_when_sub_is_null",
) )
] ]
indexes = [
models.Index(models.functions.Upper("email"), name="user_email_upper_idx"),
]
def __str__(self): def __str__(self):
return self.email or self.admin_email or str(self.id) return self.email or self.admin_email or str(self.id)
@@ -7,18 +7,18 @@ from datetime import datetime, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from django.conf import settings from django.conf import settings
from django.core.mail import send_mail from django.utils.translation import get_language, gettext, gettext_noop, override
from django.template.loader import render_to_string
from django.utils.translation import get_language, gettext, override
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import aiohttp import aiohttp
import requests import requests
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
from celery.exceptions import OperationalError as CeleryOperationalError
from livekit import api as livekit_api from livekit import api as livekit_api
from core import models, utils from core import models, utils
from core.analytics import UserFeatureFlag, is_user_feature_flag_enabled from core.analytics import UserFeatureFlag, is_user_feature_flag_enabled
from core.tasks.mail import send_email
from core.utils import generate_download_s3_url from core.utils import generate_download_s3_url
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -68,10 +68,14 @@ class NotificationService:
@staticmethod @staticmethod
def _notify_user_by_email(recording) -> bool: def _notify_user_by_email(recording) -> bool:
""" """
Send an email notification to recording owners when their recording is ready. Queue an email notification to recording owners when their recording is ready.
The email includes a direct link that redirects owners to a dedicated download The email includes a direct link that redirects owners to a dedicated download
page in the frontend where they can access their specific recording. page in the frontend where they can access their specific recording.
Rendering and SMTP delivery happen in a Celery worker (``send_email``), so
this only does one DB query and enqueues one message per owner.
Returns True when every email was queued (or sent, if Celery is disabled).
""" """
owner_accesses = ( owner_accesses = (
@@ -88,10 +92,6 @@ class NotificationService:
return False return False
context = { context = {
"brandname": settings.EMAIL_BRAND_NAME,
"support_email": settings.EMAIL_SUPPORT_EMAIL,
"logo_img": settings.EMAIL_LOGO_IMG,
"domain": settings.EMAIL_DOMAIN,
"room_name": recording.room.name, "room_name": recording.room.name,
"recording_expiration_days": settings.RECORDING_EXPIRATION_DAYS, "recording_expiration_days": settings.RECORDING_EXPIRATION_DAYS,
"link": f"{get_recording_download_base_url()}/{recording.id}", "link": f"{get_recording_download_base_url()}/{recording.id}",
@@ -104,37 +104,22 @@ class NotificationService:
# 2. The number of recipients per recording is typically small (not thousands) # 2. The number of recipients per recording is typically small (not thousands)
for access in owner_accesses: for access in owner_accesses:
user = access.user user = access.user
language = user.language or get_language() local_created_at = recording.created_at.astimezone(user.timezone)
with override(language): try:
personalized_context = { send_email.delay(
"recording_date": recording.created_at.astimezone( template="screen_recording",
user.timezone subject=gettext_noop("Your recording is ready"),
).strftime("%Y-%m-%d"), recipients=[user.email],
"recording_time": recording.created_at.astimezone( language=user.language or get_language(),
user.timezone context={
).strftime("%H:%M"), **context,
**context, "recording_date": local_created_at.strftime("%Y-%m-%d"),
} "recording_time": local_created_at.strftime("%H:%M"),
msg_html = render_to_string( },
"mail/html/screen_recording.html", personalized_context
) )
msg_plain = render_to_string( except (smtplib.SMTPException, CeleryOperationalError) as exception:
"mail/text/screen_recording.txt", personalized_context logger.error("notification could not be sent: %s", exception)
) has_failures = True
subject = gettext("Your recording is ready") # Force translation
try:
send_mail(
subject.capitalize(),
msg_plain,
settings.EMAIL_FROM,
[user.email],
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("notification could not be sent: %s", exception)
has_failures = True
return not has_failures return not has_failures
+2
View File
@@ -2,8 +2,10 @@
from core.tasks.connection_test import delete_connection_test_room from core.tasks.connection_test import delete_connection_test_room
from core.tasks.file import process_file_deletion from core.tasks.file import process_file_deletion
from core.tasks.mail import send_email
__all__ = ( __all__ = (
"delete_connection_test_room", "delete_connection_test_room",
"process_file_deletion", "process_file_deletion",
"send_email",
) )
+74
View File
@@ -0,0 +1,74 @@
"""Send mail using celery task."""
import logging
import smtplib
from django.conf import settings
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils.translation import get_language, gettext, override
from core.tasks._task import task
logger = logging.getLogger(__name__)
def get_base_email_context() -> dict:
"""Context shared by every email template (branding, support, domain)."""
return {
"brandname": settings.EMAIL_BRAND_NAME,
"support_email": settings.EMAIL_SUPPORT_EMAIL,
"logo_img": settings.EMAIL_LOGO_IMG,
"domain": settings.EMAIL_DOMAIN,
}
@task(
autoretry_for=(smtplib.SMTPException, ConnectionError, TimeoutError),
retry_backoff=True,
retry_backoff_max=600,
retry_jitter=True,
max_retries=5,
)
def send_email(
*,
template: str,
subject: str,
recipients: list[str],
context: dict | None = None,
language: str | None = None,
):
"""Render a mail template and send it.
Generic on purpose: any feature can reuse it with its own template and context.
Args:
template: Template base name, e.g. "screen_recording". Both
"mail/html/<template>.html" and "mail/text/<template>.txt" must exist.
subject: Untranslated subject msgid. Mark it with ``gettext_noop`` at the
call site; it is translated here, in ``language``.
recipients: Email addresses, all receiving the same rendered message.
context: Template context. Must be JSON-serializable (str, int, list, dict…):
pre-format dates, cast UUIDs to str. Merged over the base branding context.
language: Language to render the email in. Defaults to the active language.
Raises:
OSError: SMTP or network failure, after retries are exhausted (or
immediately when Celery is disabled and the call runs synchronously).
"""
full_context = {**get_base_email_context(), **(context or {})}
with override(language or get_language()):
msg_html = render_to_string(f"mail/html/{template}.html", full_context)
msg_plain = render_to_string(f"mail/text/{template}.txt", full_context)
translated_subject = gettext(subject)
send_mail(
translated_subject,
msg_plain,
settings.EMAIL_FROM,
recipients,
html_message=msg_html,
fail_silently=False,
)
logger.info("Email '%s' sent to %d recipient(s)", template, len(recipients))
@@ -12,6 +12,7 @@ from unittest import mock
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
import pytest import pytest
from celery.exceptions import OperationalError as CeleryOperationalError
from core import factories, models from core import factories, models
from core.analytics import UserFeatureFlag from core.analytics import UserFeatureFlag
@@ -142,7 +143,7 @@ def test_notify_user_by_email_success(mocked_current_site, settings):
notification_service = NotificationService() notification_service = NotificationService()
with mock.patch("core.recording.event.notification.send_mail") as mock_send_mail: with mock.patch("core.tasks.mail.send_mail") as mock_send_mail:
result = notification_service._notify_user_by_email(recording) result = notification_service._notify_user_by_email(recording)
assert result is True assert result is True
@@ -237,7 +238,7 @@ def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
notification_service = NotificationService() notification_service = NotificationService()
with mock.patch( with mock.patch(
"core.recording.event.notification.send_mail", "core.tasks.mail.send_mail",
side_effect=smtplib.SMTPException("SMTP Error"), side_effect=smtplib.SMTPException("SMTP Error"),
) as mock_send_mail: ) as mock_send_mail:
result = notification_service._notify_user_by_email(recording) result = notification_service._notify_user_by_email(recording)
@@ -247,6 +248,60 @@ def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
assert "notification could not be sent:" in caplog.text assert "notification could not be sent:" in caplog.text
def test_notify_user_by_email_queues_generic_task(settings):
"""One generic send_email task is queued per owner, with serializable args."""
settings.RECORDING_DOWNLOAD_BASE_URL = "https://acme.com/recordings"
settings.RECORDING_EXPIRATION_DAYS = 7
recording = factories.RecordingFactory(room__name="Conference Room A")
recording.created_at = datetime.datetime(
2023, 5, 15, 14, 30, 0, tzinfo=datetime.timezone.utc
)
user = factories.UserFactory(
email="franc@test.com", language="fr-fr", timezone="Europe/Paris"
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER, user=user
)
with mock.patch("core.recording.event.notification.send_email.delay") as mock_delay:
result = notification_service._notify_user_by_email(recording)
assert result is True
mock_delay.assert_called_once_with(
template="screen_recording",
subject="Your recording is ready",
recipients=["franc@test.com"],
language="fr-fr",
context={
"room_name": "Conference Room A",
"recording_expiration_days": 7,
"link": f"https://acme.com/recordings/{recording.id}",
"recording_date": "2023-05-15",
"recording_time": "16:30",
},
)
# Celery's JSON serializer must accept every argument
json.dumps(mock_delay.call_args.kwargs)
def test_notify_user_by_email_broker_unavailable(caplog):
"""A broker outage is reported as a failure without crashing the caller."""
recording = factories.RecordingFactory()
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER
)
with mock.patch(
"core.recording.event.notification.send_email.delay",
side_effect=CeleryOperationalError("broker down"),
):
result = notification_service._notify_user_by_email(recording)
assert result is False
assert "notification could not be sent: broker down" in caplog.text
@mock.patch("core.recording.event.notification.requests.post") @mock.patch("core.recording.event.notification.requests.post")
@mock.patch("core.recording.event.notification.generate_download_s3_url") @mock.patch("core.recording.event.notification.generate_download_s3_url")
@mock.patch.object( @mock.patch.object(
+98
View File
@@ -0,0 +1,98 @@
"""Tests for the generic send_email task."""
# pylint: disable=unused-argument,redefined-outer-name
import smtplib
from unittest import mock
from django.core.mail import get_connection
from django.utils.translation import gettext_noop
import pytest
from core.tasks.mail import send_email
@pytest.fixture
def email_settings(settings):
"""Branding settings shared by every email."""
settings.EMAIL_BRAND_NAME = "ACME"
settings.EMAIL_SUPPORT_EMAIL = "support@acme.com"
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
settings.EMAIL_DOMAIN = "acme.com"
settings.EMAIL_FROM = "notifications@acme.com"
return settings
@mock.patch("core.tasks.mail.send_mail")
@mock.patch("core.tasks.mail.render_to_string", side_effect=["<p>html</p>", "text"])
def test_send_email_renders_template_and_sends(
mock_render, mock_send_mail, email_settings
):
"""Both template variants are rendered with base + caller context, then sent."""
send_email(
template="some_template",
subject="Any subject",
recipients=["a@test.com", "b@test.com"],
context={"foo": "bar", "brandname": "Overridden"},
)
expected_context = {
"brandname": "Overridden",
"support_email": "support@acme.com",
"logo_img": "https://acme.com/logo",
"domain": "acme.com",
"foo": "bar",
}
assert mock_render.call_args_list == [
mock.call("mail/html/some_template.html", expected_context),
mock.call("mail/text/some_template.txt", expected_context),
]
mock_send_mail.assert_called_once_with(
"Any subject",
"text",
"notifications@acme.com",
["a@test.com", "b@test.com"],
html_message="<p>html</p>",
fail_silently=False,
)
@mock.patch("core.tasks.mail.send_mail")
def test_send_email_translates_subject_in_requested_language(
mock_send_mail, email_settings
):
"""The subject msgid is translated in the worker, in the recipient language."""
send_email(
template="screen_recording",
subject=gettext_noop("Your recording is ready"),
recipients=["franc@test.com"],
context={"room_name": "Room", "link": "https://acme.com/r/1"},
language="fr-fr",
)
subject, body, *_ = mock_send_mail.call_args[0]
assert subject == "Votre enregistrement est prêt"
assert "Votre enregistrement est prêt !" in body
@mock.patch(
"core.tasks.mail.send_mail", side_effect=smtplib.SMTPException("SMTP Error")
)
@mock.patch("core.tasks.mail.render_to_string", return_value="content")
def test_send_email_raises_on_smtp_error(mock_render, mock_send_mail, email_settings):
"""SMTP errors propagate so Celery can retry the task."""
with pytest.raises(smtplib.SMTPException):
send_email(
template="some_template",
subject="Any subject",
recipients=["a@test.com"],
)
def test_smtp_backend_uses_email_timeout(settings):
"""A stalled SMTP server cannot hold the worker forever."""
settings.EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
settings.EMAIL_TIMEOUT = 12
assert get_connection().timeout == 12
@@ -35,6 +35,20 @@ def test_user_access_token_users_me():
assert response.data["email"] == user.email assert response.data["email"] == user.email
def test_user_access_token_custom_token_type(settings):
"""The Authorization scheme should match the configured token type."""
settings.USER_ACCESS_TOKEN_TYPE = "Token"
user = UserFactory()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Token {generate_user_access_token(user)}")
response = client.get("/api/v1.0/users/me/")
assert response.status_code == 200
assert response.data["email"] == user.email
def test_user_access_token_expired(): def test_user_access_token_expired():
"""An expired user access token should be rejected.""" """An expired user access token should be rejected."""
user = UserFactory() user = UserFactory()
@@ -67,6 +67,19 @@ def test_resource_server_backend_get_or_create_user_no_creation(settings):
assert not User.objects.filter(sub="new-sub").exists() assert not User.objects.filter(sub="new-sub").exists()
def test_resource_server_backend_get_or_create_user_invalid_sub(settings):
"""A sub longer than 255 characters should be rejected without creating a user."""
settings.OIDC_CREATE_USER = True
with pytest.raises(SuspiciousOperation, match="invalid sub claim"):
ResourceServerBackend().get_or_create_user(
access_token="token", id_token=None, payload=_payload("a" * 256)
)
assert not User.objects.exists()
@responses.activate @responses.activate
def test_api_rooms_list_resource_server_inactive_user(settings): def test_api_rooms_list_resource_server_inactive_user(settings):
"""End to end: a valid introspected token for an inactive user should get 401.""" """End to end: a valid introspected token for an inactive user should get 401."""
@@ -0,0 +1,302 @@
"""Tests for the external API MenshenAuthentication."""
from unittest import mock
import pytest
import responses
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from rest_framework.test import APIClient
from core.factories import UserFactory
from core.models import RoleChoices, Room, User
pytestmark = pytest.mark.django_db
SERVER_URL = "https://menshen.example.com"
INTROSPECTION_ENDPOINT = f"{SERVER_URL}/auth/token/introspect/"
TOKEN = "menshen-exchanged-token"
@pytest.fixture(autouse=True)
def menshen_settings(settings):
"""Enable Menshen authentication."""
settings.MENSHEN_ENABLED = True
settings.MENSHEN_SERVER_URL = SERVER_URL
settings.MENSHEN_CLIENT_ID = "meet"
settings.MENSHEN_CLIENT_SECRET = "meet-secret"
def _introspection(sub, scope="meet:room:create", **overrides):
return {
"active": True,
"sub": sub,
"email": "user@example.com",
"scope": scope,
"aud": "meet",
"client_id": "menshen",
**overrides,
}
def _create_room(token=TOKEN):
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
return client.post("/external-api/v1.0/rooms/", {}, format="json")
@responses.activate
def test_menshen_active_token():
"""An active token with a mapped scope should create a room owned by the user."""
user = UserFactory()
# Catch and mock external HTTP requests
responses.add(
responses.POST,
INTROSPECTION_ENDPOINT,
json=_introspection(user.sub),
match=[responses.matchers.urlencoded_params_matcher({"token": TOKEN})],
)
response = _create_room()
assert response.status_code == 201
room = Room.objects.get(id=response.data["id"])
assert room.get_role(user) == RoleChoices.OWNER
assert responses.calls[0].request.headers["Authorization"].startswith("Basic ")
@responses.activate
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
def test_menshen_other_token_type_ignored(mock_rs_authenticate):
"""A token sent with another scheme than Bearer should not be introspected."""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Token {TOKEN}")
response = client.post("/external-api/v1.0/rooms/", {}, format="json")
assert response.status_code == 401
assert len(responses.calls) == 0
mock_rs_authenticate.assert_called_once()
@responses.activate
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
def test_menshen_disabled(mock_rs_authenticate, settings):
"""Menshen should not be called when disabled."""
settings.MENSHEN_ENABLED = False
response = _create_room()
assert response.status_code == 401
assert len(responses.calls) == 0
mock_rs_authenticate.assert_called_once()
@responses.activate
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
def test_menshen_inactive_token_defers(mock_rs_authenticate):
"""An inactive token should defer to the next authentication backend."""
user = UserFactory()
responses.add(
responses.POST,
INTROSPECTION_ENDPOINT,
json=_introspection(user.sub, scope="meet:room:create", active=False),
)
response = _create_room()
assert response.status_code == 401
mock_rs_authenticate.assert_called_once()
@responses.activate
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
def test_menshen_error_defers(mock_rs_authenticate):
"""A Menshen error should defer to the next authentication backend."""
responses.add(responses.POST, INTROSPECTION_ENDPOINT, status=500)
response = _create_room()
assert response.status_code == 401
mock_rs_authenticate.assert_called_once()
@responses.activate
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
def test_menshen_unparsable_response_defers(mock_rs_authenticate):
"""A response the client can't parse should defer to the next backend."""
responses.add(
responses.POST,
INTROSPECTION_ENDPOINT,
json={"active": True, "unexpected": "field"},
)
response = _create_room()
assert response.status_code == 401
mock_rs_authenticate.assert_called_once()
@responses.activate
def test_menshen_resource_server_token(settings):
"""End to end: a token issued by the OIDC provider is unknown to Menshen and
should still authenticate through the resource server."""
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
settings.OIDC_OP_URL = "https://oidc.example.com"
user = UserFactory()
token = "oidc-access-token"
menshen_introspection = responses.add(
responses.POST,
INTROSPECTION_ENDPOINT,
json={"active": False},
match=[responses.matchers.urlencoded_params_matcher({"token": token})],
)
oidc_introspection = responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"active": True,
"sub": user.sub,
"scope": "openid lasuite_meet rooms:create",
"client_id": "app",
},
)
response = _create_room(token)
assert response.status_code == 201
room = Room.objects.get(id=response.data["id"])
assert room.get_role(user) == RoleChoices.OWNER
assert menshen_introspection.call_count == 1
assert oidc_introspection.call_count == 1
@responses.activate
def test_menshen_unmapped_scope():
"""Scopes granted for other services or other Meet actions should not grant access."""
user = UserFactory()
responses.add(
responses.POST,
INTROSPECTION_ENDPOINT,
json=_introspection(user.sub, scope="drive:item:create meet:room:list"),
)
response = _create_room()
assert response.status_code == 403
assert "insufficient permissions." in str(response.data).lower()
assert not Room.objects.exists()
@responses.activate
def test_menshen_missing_sub():
"""An active token without sub should be rejected."""
responses.add(responses.POST, INTROSPECTION_ENDPOINT, json=_introspection(None))
response = _create_room()
assert response.status_code == 401
assert "invalid token claims." in str(response.data).lower()
@responses.activate
def test_menshen_invalid_sub(settings):
"""An active token with an invalid sub should be rejected without creating a user."""
settings.OIDC_CREATE_USER = True
responses.add(
responses.POST, INTROSPECTION_ENDPOINT, json=_introspection("invalid\nsub")
)
response = _create_room()
assert response.status_code == 401
assert "invalid sub claim" in str(response.data).lower()
assert not User.objects.exists()
@responses.activate
def test_menshen_inactive_user():
"""An active token for an inactive user should be rejected."""
user = UserFactory(is_active=False)
responses.add(responses.POST, INTROSPECTION_ENDPOINT, json=_introspection(user.sub))
response = _create_room()
assert response.status_code == 401
assert "user account is disabled." in str(response.data).lower()
@responses.activate
def test_menshen_unknown_user_created(settings):
"""An unknown sub should create a user when OIDC_CREATE_USER is set."""
settings.OIDC_CREATE_USER = True
responses.add(
responses.POST, INTROSPECTION_ENDPOINT, json=_introspection("new-sub")
)
response = _create_room()
assert response.status_code == 201
user = User.objects.get(sub="new-sub")
assert user.email == "user@example.com"
assert user.is_active is True
@responses.activate
def test_menshen_unknown_user_not_created(settings):
"""An unknown sub should be rejected when OIDC_CREATE_USER is unset."""
settings.OIDC_CREATE_USER = False
responses.add(
responses.POST, INTROSPECTION_ENDPOINT, json=_introspection("new-sub")
)
response = _create_room()
assert response.status_code == 401
assert "user not found." in str(response.data).lower()
assert not User.objects.filter(sub="new-sub").exists()
@responses.activate
@pytest.mark.parametrize("existing_sub", [None, "other-sub"])
def test_menshen_unknown_user_email_already_used(settings, existing_sub):
"""An unknown sub should not create a second account with an email already
used, by a provisional user or a user with another sub."""
settings.OIDC_CREATE_USER = True
existing_user = UserFactory(sub=existing_sub, email="User@Example.com")
responses.add(
responses.POST, INTROSPECTION_ENDPOINT, json=_introspection("new-sub")
)
response = _create_room()
assert response.status_code == 401
assert "email already used by another account." in str(response.data).lower()
assert User.objects.count() == 1
existing_user.refresh_from_db()
assert existing_user.sub == existing_sub
assert not Room.objects.exists()
+1 -1
View File
@@ -69,7 +69,7 @@ def test_models_users_sub_validator(sub, is_valid):
else: else:
with pytest.raises( with pytest.raises(
ValidationError, ValidationError,
match="Enter a valid sub. This value should be printable ASCII only.", match="Enter a valid sub. This value should be printable ASCII only, with 255 characters or less.",
): ):
user.full_clean() user.full_clean()
+9 -3
View File
@@ -17,7 +17,13 @@ def sub_validator(value):
``isprintable()`` is false exactly for those control characters, while ``isprintable()`` is false exactly for those control characters, while
space (U+0020) remains allowed. space (U+0020) remains allowed.
""" """
msg = "Enter a valid sub. This value should be printable ASCII only, with 255 characters or less."
if value is None:
raise ValidationError(_(msg))
if len(value) > 255:
raise ValidationError(_(msg))
if not value.isascii() or not value.isprintable(): if not value.isascii() or not value.isprintable():
raise ValidationError( raise ValidationError(_(msg))
_("Enter a valid sub. This value should be printable ASCII only.")
)
+15
View File
@@ -508,6 +508,7 @@ class Base(Configuration):
EMAIL_PORT = values.PositiveIntegerValue(None) EMAIL_PORT = values.PositiveIntegerValue(None)
EMAIL_USE_TLS = values.BooleanValue(False) EMAIL_USE_TLS = values.BooleanValue(False)
EMAIL_USE_SSL = values.BooleanValue(False) EMAIL_USE_SSL = values.BooleanValue(False)
EMAIL_TIMEOUT = values.PositiveIntegerValue(30)
EMAIL_FROM = values.Value("from@example.com") EMAIL_FROM = values.Value("from@example.com")
EMAIL_BRAND_NAME = values.Value(None) EMAIL_BRAND_NAME = values.Value(None)
EMAIL_SUPPORT_EMAIL = values.Value(None) EMAIL_SUPPORT_EMAIL = values.Value(None)
@@ -708,6 +709,20 @@ class Base(Configuration):
default=None, environ_name="OIDC_RS_SCOPES_PREFIX", environ_prefix=None default=None, environ_name="OIDC_RS_SCOPES_PREFIX", environ_prefix=None
) )
# Menshen, La Suite's OAuth 2.0 token exchange server
MENSHEN_ENABLED = values.BooleanValue(
False, environ_name="MENSHEN_ENABLED", environ_prefix=None
)
MENSHEN_SERVER_URL = values.Value(
None, environ_name="MENSHEN_SERVER_URL", environ_prefix=None
)
MENSHEN_CLIENT_ID = values.Value(
None, environ_name="MENSHEN_CLIENT_ID", environ_prefix=None
)
MENSHEN_CLIENT_SECRET = SecretFileValue(
None, environ_name="MENSHEN_CLIENT_SECRET", environ_prefix=None
)
# Video conference configuration # Video conference configuration
LIVEKIT_CONFIGURATION = { LIVEKIT_CONFIGURATION = {
"api_key": SecretFileValue(environ_name="LIVEKIT_API_KEY", environ_prefix=None), "api_key": SecretFileValue(environ_name="LIVEKIT_API_KEY", environ_prefix=None),
+2 -1
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project] [project]
name = "meet" name = "meet"
version = "1.34.0" version = "1.35.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }] authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [ classifiers = [
"Development Status :: 5 - Production/Stable", "Development Status :: 5 - Production/Stable",
@@ -49,6 +49,7 @@ dependencies = [
"gunicorn==26.2.0", "gunicorn==26.2.0",
"jsonschema==4.26.0", "jsonschema==4.26.0",
"markdown==3.10.3", "markdown==3.10.3",
"menshen-client==0.2.0",
"nested-multipart-parser==1.6.0", "nested-multipart-parser==1.6.0",
"posthog==7.44.0", "posthog==7.44.0",
"psycopg[binary]==3.3.4", "psycopg[binary]==3.3.4",
+16 -2
View File
@@ -1,5 +1,5 @@
version = 1 version = 1
revision = 3 revision = 5
requires-python = ">=3.13" requires-python = ">=3.13"
[[package]] [[package]]
@@ -1297,7 +1297,7 @@ wheels = [
[[package]] [[package]]
name = "meet" name = "meet"
version = "1.34.0" version = "1.35.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiohttp" }, { name = "aiohttp" },
@@ -1327,6 +1327,7 @@ dependencies = [
{ name = "jsonschema" }, { name = "jsonschema" },
{ name = "livekit-api" }, { name = "livekit-api" },
{ name = "markdown" }, { name = "markdown" },
{ name = "menshen-client" },
{ name = "mozilla-django-oidc" }, { name = "mozilla-django-oidc" },
{ name = "nested-multipart-parser" }, { name = "nested-multipart-parser" },
{ name = "phonenumbers" }, { name = "phonenumbers" },
@@ -1392,6 +1393,7 @@ requires-dist = [
{ name = "jsonschema", specifier = "==4.26.0" }, { name = "jsonschema", specifier = "==4.26.0" },
{ name = "livekit-api", specifier = "==1.2.0" }, { name = "livekit-api", specifier = "==1.2.0" },
{ name = "markdown", specifier = "==3.10.3" }, { name = "markdown", specifier = "==3.10.3" },
{ name = "menshen-client", specifier = "==0.2.0" },
{ name = "mozilla-django-oidc", specifier = "==5.0.2" }, { name = "mozilla-django-oidc", specifier = "==5.0.2" },
{ name = "nested-multipart-parser", specifier = "==1.6.0" }, { name = "nested-multipart-parser", specifier = "==1.6.0" },
{ name = "phonenumbers", specifier = "==9.0.37" }, { name = "phonenumbers", specifier = "==9.0.37" },
@@ -1428,6 +1430,18 @@ dev = [
{ name = "types-requests", specifier = "==2.33.0.20260712" }, { name = "types-requests", specifier = "==2.33.0.20260712" },
] ]
[[package]]
name = "menshen-client"
version = "0.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/57/a2/5862b0a39d5f96301e1daff146445f591b1980412c20417162f5cfd6aa34/menshen_client-0.2.0.tar.gz", hash = "sha256:548c4224c38b2c844a76325b5a4b65da520ea9eba23aafb004da0f430a7e4fcf", size = 6326, upload-time = "2026-10-13T10:19:34.344Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/53/4d/932efd81c340d12d07b1c33633c6e7a4a2d0c9086ac0da7ffe9a1ce17068/menshen_client-0.2.0-py3-none-any.whl", hash = "sha256:415eaba30184452bc393b528c6088ed6e37cbd00ce60716aef9e2d9bd0697221", size = 7149, upload-time = "2026-10-13T10:19:33.272Z" },
]
[[package]] [[package]]
name = "mozilla-django-oidc" name = "mozilla-django-oidc"
version = "5.0.2" version = "5.0.2"
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "meet", "name": "meet",
"version": "1.34.0", "version": "1.35.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "meet", "name": "meet",
"version": "1.34.0", "version": "1.35.0",
"dependencies": { "dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0", "@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
"@fontsource-variable/lexend": "5.3.0", "@fontsource-variable/lexend": "5.3.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "meet", "name": "meet",
"private": true, "private": true,
"version": "1.34.0", "version": "1.35.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "panda codegen && vite", "dev": "panda codegen && vite",
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "mail_mjml", "name": "mail_mjml",
"version": "1.34.0", "version": "1.35.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mail_mjml", "name": "mail_mjml",
"version": "1.34.0", "version": "1.35.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@html-to/text-cli": "0.6.1", "@html-to/text-cli": "0.6.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mail_mjml", "name": "mail_mjml",
"version": "1.34.0", "version": "1.35.0",
"description": "An util to generate html and text django's templates from mjml templates", "description": "An util to generate html and text django's templates from mjml templates",
"type": "module", "type": "module",
"dependencies": { "dependencies": {
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "sdk", "name": "sdk",
"version": "1.34.0", "version": "1.35.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sdk", "name": "sdk",
"version": "1.34.0", "version": "1.35.0",
"license": "ISC", "license": "ISC",
"workspaces": [ "workspaces": [
"./library", "./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "sdk", "name": "sdk",
"version": "1.34.0", "version": "1.35.0",
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"description": "", "description": "",
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "summary" name = "summary"
version = "1.34.0" version = "1.35.0"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = [
"fastapi[standard]>=0.105.0", "fastapi[standard]>=0.105.0",
+1 -1
View File
@@ -1484,7 +1484,7 @@ wheels = [
[[package]] [[package]]
name = "summary" name = "summary"
version = "1.34.0" version = "1.35.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "boto3" }, { name = "boto3" },