Compare commits

..

3 Commits

Author SHA1 Message Date
briquet e3b6b7e213 ⚡️(backend) add opt-in psycopg connection pool
Each request opened and closed its own Postgres connection which added a
big overhead. The goal is to add Django's native psycopg pool behind a new
DB_PSYCOPG_POOL_ENABLED settings with explicit min/max size and keepalive.

Each pool lives in one process, and gunicorn's sync workers serve a single
request at a time which means that a given worker won't use more than one
connection. The default pool minimum size is set to 1, which keeps always
one connection open and the maximum size to 3 as headroom for code touching
the database from an extra thread.

Pooled connections can sit idle for hours, so TCP keepalives are enabled
to keep load balancers and NAT from dropping them silently, and each
connection is health checked on checkout so a broken one is replaced
instead of failing the request.
2026-10-14 23:30:02 +02:00
Roee Sivan eb422309b9 ✨(backend) allow static feature flags without an analytics backend
Feature flags are read from the analytics backend. Without one,
NoOpAnalytics returned no flag, so "summary-enabled" was always off and
a self-hosted instance could not get automatic summaries without PostHog.

NoOpAnalytics now accepts a static "feature_flags" mapping through the
existing ANALYTICS_BACKEND_SETTINGS, and get_analytics() passes the
settings to it. Nothing changes when no flag is declared.

Fixes #1610
2026-10-14 00:19:55 +02:00
leo 4854fc0c13 🔒️(devex) bind ports in compose.yaml to localhost
Some containers were being exposed to local networks. Bind them
to localhost, to tighten security. Don't apply this binding to
LiveKit (exposed on 7880), as we access it using 127.0.0.1.nip.io:7880.
2026-10-13 18:21:04 +02:00
23 changed files with 480 additions and 620 deletions
+2 -1
View File
@@ -11,7 +11,6 @@ and this project adheres to
### 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
@@ -20,6 +19,8 @@ and this project adheres to
- ✨(frontend) let users pick the recording encoding
- ✨(backend) introduce a token exchange endpoint for iframe embeds
- ⚡️(backend) send recording notification emails from a Celery task
- ✨(backend) allow static feature flags without an analytics backend #1809
- ⚡️(backend) add an opt-in psycopg connection pool
### Changed
+13 -13
View File
@@ -5,17 +5,17 @@ services:
env_file:
- env.d/development/postgresql
ports:
- "15432:5432"
- "127.0.0.1:15432:5432"
redis:
image: redis:5
ports:
- "6379:6379"
- "127.0.0.1:6379:6379"
mailcatcher:
image: sj26/mailcatcher:latest
ports:
- "1081:1080"
- "127.0.0.1:1081:1080"
garage:
user: ${DOCKER_USER:-1000}
@@ -71,7 +71,7 @@ services:
- env.d/development/common
- env.d/development/postgresql
ports:
- "8071:8000"
- "127.0.0.1:8071:8000"
volumes:
- ./src/backend:/app
- ./data/static:/data/static
@@ -137,7 +137,7 @@ services:
nginx:
image: nginx:1.25
ports:
- "8083:8083"
- "127.0.0.1:8083:8083"
volumes:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
depends_on:
@@ -159,7 +159,7 @@ services:
VITE_APP_TITLE: "LaSuite Meet"
image: meet:frontend-development
ports:
- "3000:8080"
- "127.0.0.1:3000:8080"
dockerize:
image: jwilder/dockerize
@@ -185,7 +185,7 @@ services:
kc_postgresql:
image: postgres:14.3
ports:
- "5433:5432"
- "127.0.0.1:5433:5432"
env_file:
- env.d/development/kc_postgresql
@@ -213,7 +213,7 @@ services:
KC_DB_SCHEMA: public
PROXY_ADDRESS_FORWARDING: 'true'
ports:
- "8080:8080"
- "127.0.0.1:8080:8080"
depends_on:
- kc_postgresql
@@ -222,10 +222,10 @@ services:
entrypoint: /livekit-server --dev --bind 0.0.0.0 --config ./config.yaml
ports:
- "7880:7880"
- "7881:7881"
- "7882:7882/udp"
- "3478:3478/udp"
- "30000-30100:30000-30100/udp"
- "127.0.0.1:7881:7881"
- "127.0.0.1:7882:7882/udp"
- "127.0.0.1:3478:3478/udp"
- "127.0.0.1:30000-30100:30000-30100/udp"
volumes:
- ./docker/livekit/config/livekit-server.yaml:/config.yaml
depends_on:
@@ -283,7 +283,7 @@ services:
env_file:
- env.d/development/summary
ports:
- "8001:8000"
- "127.0.0.1:8001:8000"
volumes:
- ./src/summary:/app
depends_on:
+1 -1
View File
@@ -45,4 +45,4 @@ services:
# The local proxy listens on 8080 and collides with Keycloak's published admin port.
keycloak:
ports: !override
- "8081:8080"
- "127.0.0.1:8081:8080"
+20
View File
@@ -120,6 +120,26 @@ DJANGO_EMAIL_BRAND_NAME=<brand name used in email templates> # e.g. "La Suite Nu
DJANGO_EMAIL_LOGO_IMG=<logo image to use in email templates.> # e.g. "https://meet.yourdomain.tld/assets/logo-suite-numerique.png"
```
### Feature flags without an analytics backend
Some features, such as automatic meeting summaries (`summary-enabled`), are gated by a feature flag read from the analytics backend. You do not need an analytics backend to enable them: leave `ANALYTICS_BACKEND` unset and declare the flags in `env.d/common`. No data is sent anywhere.
```env
ANALYTICS_BACKEND_SETTINGS={"feature_flags": {"summary-enabled": True}}
```
You can also set it directly in `compose.yaml`:
```yaml
services:
backend:
environment:
ANALYTICS_BACKEND_SETTINGS: '{"feature_flags": {"summary-enabled": True}}'
```
> [!NOTE]
> The value is a Python dictionary, not JSON: write `True`, not `true`. Set it on every container running the backend image.
## Step 3: Configure your firewall
If you are using a firewall as it is usually recommended in a production environment you will need to allow the webservice traffic on ports 80 and 443 but also to allow UDP traffic for the WebRTC service.
+8 -1
View File
@@ -318,12 +318,17 @@ These are the environmental options available on meet backend.
| DJANGO_SECRET_KEY | Secret key used for Django security | |
| DJANGO_SILENCED_SYSTEM_CHECKS | Silence Django system checks | [] |
| DJANGO_ALLOW_UNSECURE_USER_LISTING | Allow unsecure user listing | false |
| DB_ENGINE | Database engine used | django.db.backends.postgresql_psycopg2 |
| DB_ENGINE | Database engine used | django.db.backends.postgresql |
| DB_NAME | Name of the database | meet |
| DB_USER | User used to connect to database | dinum |
| DB_PASSWORD | Password used to connect to the database | pass |
| DB_HOST | Hostname of the database | localhost |
| DB_PORT | Port to connect to database | 5432 |
| DB_PSYCOPG_POOL_ENABLED | Enable the psycopg connection pool | false |
| DB_PSYCOPG_POOL_MIN_SIZE | Connections each pool keeps open | 1 |
| DB_PSYCOPG_POOL_MAX_SIZE | Maximum connections per pool | 3 |
| DB_PSYCOPG_POOL_TIMEOUT | Seconds a request waits for a free pool connection before failing | 30 |
| DB_PSYCOPG_POOL_MAX_IDLE | Seconds an idle connection above the minimum stays open | 600 |
| STORAGES_STATICFILES_BACKEND | Static file serving engine | whitenoise.storage.CompressedManifestStaticFilesStorage |
| AWS_S3_ENDPOINT_URL | S3 host endpoint | |
| AWS_S3_ACCESS_KEY_ID | S3 access key | |
@@ -416,6 +421,8 @@ These are the environmental options available on meet backend.
| SCREEN_RECORDING_BASE_URL | Screen recording base URL | |
| SUMMARY_SERVICE_ENDPOINT | Summary service endpoint | |
| SUMMARY_SERVICE_API_TOKEN | API token for summary service | |
| ANALYTICS_BACKEND | Analytics backend class, e.g. `core.analytics.posthog.PostHogAnalytics`. Nothing is sent when unset | |
| ANALYTICS_BACKEND_SETTINGS | Analytics backend settings. Without a backend, `{"feature_flags": {"summary-enabled": True}}` sets feature flags statically | {} |
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Signup users to marketing emails | false |
| MARKETING_SERVICE_CLASS | Marketing service class | core.services.marketing.BrevoMarketingService |
| BREVO_API_KEY | Brevo API key for marketing emails | |
+2 -1
View File
@@ -8,4 +8,5 @@ DB_HOST=postgresql
DB_NAME=meet
DB_USER=dinum
DB_PASSWORD=pass
DB_PORT=5432
DB_PORT=5432
DB_PSYCOPG_POOL_ENABLED=True
+1 -1
View File
@@ -39,7 +39,7 @@ def get_analytics() -> AnalyticsBackend:
options = getattr(settings, "ANALYTICS_BACKEND_SETTINGS", {}) or {}
if not dotted_path:
return NoOpAnalytics()
return NoOpAnalytics(**options)
backend_class = import_string(dotted_path)
return backend_class(**options)
+24 -1
View File
@@ -53,7 +53,23 @@ class AnalyticsBackend(ABC):
class NoOpAnalytics(AnalyticsBackend):
"""Default backend: silently discards everything."""
"""
Default backend: discards events and serves static feature flags.
Feature flags can be declared without any analytics provider, e.g.:
ANALYTICS_BACKEND_SETTINGS = {"feature_flags": {"summary-enabled": True}}
"""
def __init__(
self,
*,
feature_flags: Mapping[str, bool | str | None] | None = None,
**_kwargs: Any,
) -> None:
# Other settings are ignored, so that settings meant for another
# backend never break an instance running without one.
self._feature_flags = dict(feature_flags or {})
def identify(self, user: User, properties=None) -> None:
"""No-op: discards identify calls."""
@@ -63,3 +79,10 @@ class NoOpAnalytics(AnalyticsBackend):
def shutdown(self) -> None:
"""No-op: nothing to flush."""
def get_user_feature_flags(
self,
user: User,
) -> Mapping[UserFeatureFlag, bool | str | None]:
"""Return the feature flags declared in settings, for any user."""
return self._feature_flags
@@ -82,6 +82,9 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
"User info contained an invalid sub claim"
) from err
if len(sub) > 255:
raise SuspiciousOperation("User info contained an invalid sub claim")
try:
return User.objects.get(sub=sub)
except User.DoesNotExist:
+42 -204
View File
@@ -4,113 +4,21 @@
# ruff: noqa: PLR0913
import logging
from dataclasses import asdict
from functools import lru_cache
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import SuspiciousOperation, ValidationError
from django.core.exceptions import SuspiciousOperation
import requests
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 core.models import Application, ApplicationScope
from core.models import Application
from core.services import jwt_token
from core.validators import sub_validator
User = get_user_model()
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
"""
if sub is not None:
sub = str(sub)
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 (
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION
and 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):
"""Base JWT authentication class."""
@@ -164,12 +72,25 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
if not self.is_enabled:
return None
token = get_token(request, self._token_type)
auth_header = authentication.get_authorization_header(request).split()
if token is None:
if (
not auth_header
or auth_header[0].lower() != self._token_type.lower().encode()
):
# Defer to next authentication backend
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)
def decode_jwt(self, token):
@@ -335,113 +256,6 @@ 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():
return TokenExchangeClient(
config=Configuration(
settings.MENSHEN_CLIENT_ID,
settings.MENSHEN_CLIENT_SECRET,
settings.MENSHEN_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 authenticate(self, request):
"""Introspect the bearer token with Menshen.
Returns:
Tuple of (user, payload) if the token is active, None otherwise
"""
if not settings.MENSHEN_ENABLED:
return None
token = get_token(request, "bearer")
if token is None:
return None
client = _get_token_exchange_client()
try:
response = 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):
"""OIDC Resource Server backend for user creation and retrieval."""
@@ -471,4 +285,28 @@ class ResourceServerBackend(LaSuiteBackend):
logger.debug(message)
raise SuspiciousOperation(message)
return get_or_create_user_by_sub(sub)
user = self.get_user(access_token, id_token, payload)
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,7 +166,6 @@ class RoomViewSet(
authentication_classes = [
authentication.ApplicationJWTAuthentication,
authentication.AddonsJWTAuthentication,
authentication.MenshenAuthentication,
ResourceServerAuthentication,
]
permission_classes = [
@@ -0,0 +1,124 @@
"""
Unit tests for NoOpAnalytics and the analytics backend loader.
"""
# pylint: disable=redefined-outer-name,unused-argument
import pytest
from core.analytics import get_analytics
from core.analytics.base import NoOpAnalytics
from core.analytics.user_feature_flags import UserFeatureFlag
from core.factories import UserFactory
pytestmark = pytest.mark.django_db
@pytest.fixture(autouse=True)
def clear_analytics_cache():
"""Clear the cached backend before and after each test."""
get_analytics.cache_clear()
yield
get_analytics.cache_clear()
# ==============================
# NoOpAnalytics
# ==============================
def test_noop_has_no_feature_flags_by_default():
"""Should keep every feature flag disabled when none is declared."""
backend = NoOpAnalytics()
user = UserFactory()
assert not backend.get_user_feature_flags(user)
assert (
backend.is_user_feature_enabled(
user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
)
is False
)
def test_noop_enables_declared_feature_flags():
"""Should enable a feature flag declared as True."""
backend = NoOpAnalytics(feature_flags={"summary-enabled": True})
user = UserFactory()
assert backend.get_user_feature_flags(user) == {"summary-enabled": True}
assert (
backend.is_user_feature_enabled(
user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
)
is True
)
@pytest.mark.parametrize("value", [False, None, "true"])
def test_noop_only_enables_feature_flags_set_to_true(value):
"""Should keep a feature flag disabled unless its value is exactly True."""
backend = NoOpAnalytics(feature_flags={"summary-enabled": value})
user = UserFactory()
assert (
backend.is_user_feature_enabled(
user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
)
is False
)
def test_noop_ignores_unrelated_settings():
"""Should accept settings meant for another backend without failing."""
backend = NoOpAnalytics(api_key="my-key", host="https://eu.i.posthog.com")
assert not backend.get_user_feature_flags(UserFactory())
# ==============================
# get_analytics
# ==============================
def test_get_analytics_defaults_to_noop_without_feature_flags(settings):
"""Should return a no-op backend with no feature flag by default."""
settings.ANALYTICS_BACKEND = None
settings.ANALYTICS_BACKEND_SETTINGS = {}
backend = get_analytics()
assert isinstance(backend, NoOpAnalytics)
assert not backend.get_user_feature_flags(UserFactory())
def test_get_analytics_passes_feature_flags_to_noop(settings):
"""Should forward the feature flags when no backend is configured."""
settings.ANALYTICS_BACKEND = None
settings.ANALYTICS_BACKEND_SETTINGS = {"feature_flags": {"summary-enabled": True}}
backend = get_analytics()
assert isinstance(backend, NoOpAnalytics)
assert (
backend.is_user_feature_enabled(
UserFactory(), UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
)
is True
)
def test_get_analytics_instantiates_configured_backend_with_settings(settings):
"""Should instantiate the configured backend with its settings."""
settings.ANALYTICS_BACKEND = "core.analytics.base.NoOpAnalytics"
settings.ANALYTICS_BACKEND_SETTINGS = {"feature_flags": {"summary-enabled": True}}
backend = get_analytics()
assert isinstance(backend, NoOpAnalytics)
assert (
backend.is_user_feature_enabled(
UserFactory(), UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
)
is True
)
@@ -6,6 +6,7 @@ from concurrent.futures import ThreadPoolExecutor
from urllib.parse import parse_qs, urlparse
from uuid import uuid4
from django.db import connection
from django.utils import timezone
import pytest
@@ -361,18 +362,21 @@ def test_api_files_create_file_race_condition():
"""
def create_item(title):
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
return client.post(
"/api/v1.0/files/",
{
"title": title,
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": "my_file.png",
},
format="json",
)
try:
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
return client.post(
"/api/v1.0/files/",
{
"title": title,
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": "my_file.png",
},
format="json",
)
finally:
connection.close()
with ThreadPoolExecutor(max_workers=2) as executor:
future1 = executor.submit(create_item, "my item 1")
@@ -5,6 +5,7 @@ from concurrent.futures import ThreadPoolExecutor
from io import BytesIO
from django.core.files.storage import default_storage
from django.db import connection
import pytest
from rest_framework.test import APIClient
@@ -295,9 +296,12 @@ def test_api_file_upload_ended_concurrent_calls_are_serialized(settings):
default_storage.save(file.temporary_file_key, BytesIO(b"my prose"))
def call_upload_ended():
client = APIClient()
client.force_login(user)
return client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
try:
client = APIClient()
client.force_login(user)
return client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
finally:
connection.close()
with ThreadPoolExecutor(max_workers=2) as executor:
futures = [
@@ -35,20 +35,6 @@ def test_user_access_token_users_me():
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():
"""An expired user access token should be rejected."""
user = UserFactory()
@@ -67,19 +67,6 @@ def test_resource_server_backend_get_or_create_user_no_creation(settings):
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
def test_api_rooms_list_resource_server_inactive_user(settings):
"""End to end: a valid introspected token for an inactive user should get 401."""
@@ -1,304 +0,0 @@
"""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"
responses.add(
responses.POST,
INTROSPECTION_ENDPOINT,
json={"active": False},
match=[responses.matchers.urlencoded_params_matcher({"token": token})],
)
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
responses.assert_call_count(INTROSPECTION_ENDPOINT, 1)
responses.assert_call_count("https://oidc.example.com/introspect", 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, when
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION=True."""
settings.OIDC_CREATE_USER = True
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = 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 -4
View File
@@ -69,10 +69,7 @@ def test_models_users_sub_validator(sub, is_valid):
else:
with pytest.raises(
ValidationError,
match=(
"Enter a valid sub. This value should be printable ASCII only, "
"with 255 characters or less."
),
match="Enter a valid sub. This value should be printable ASCII only.",
):
user.full_clean()
@@ -0,0 +1,130 @@
"""Unit tests for the opt-in psycopg connection pool settings."""
# pylint: disable=protected-access
from django.core.exceptions import ImproperlyConfigured
import pytest
from meet import settings
def make_configuration(**overrides):
"""Build a throwaway configuration with plain values and its own DATABASES."""
attributes = {
"DATABASES": {
"default": {
"ENGINE": "django.db.backends.postgresql",
"OPTIONS": {"sslmode": "require"},
}
},
"DB_PSYCOPG_POOL_ENABLED": True,
"DB_PSYCOPG_POOL_MIN_SIZE": 1,
"DB_PSYCOPG_POOL_MAX_SIZE": 3,
"DB_PSYCOPG_POOL_TIMEOUT": 30.0,
"DB_PSYCOPG_POOL_MAX_IDLE": 600.0,
**overrides,
}
return type("PoolConfiguration", (settings.Test,), attributes)
def test_database_pool_disabled_leaves_options_untouched():
"""Without the flag, no connection pool is configured."""
configuration = make_configuration(DB_PSYCOPG_POOL_ENABLED=False)
configuration._configure_database_pool()
assert configuration.DATABASES["default"]["OPTIONS"] == {"sslmode": "require"}
assert "CONN_HEALTH_CHECKS" not in configuration.DATABASES["default"]
def test_database_pool_enabled_merges_pool_options():
"""The pool and keepalive options are added next to the existing ones."""
configuration = make_configuration()
configuration._configure_database_pool()
assert configuration.DATABASES["default"]["OPTIONS"] == {
"sslmode": "require",
"keepalives_idle": 60,
"keepalives_interval": 10,
"keepalives_count": 5,
"pool": {"min_size": 1, "max_size": 3, "timeout": 30.0, "max_idle": 600.0},
}
def test_database_pool_enabled_keeps_explicit_keepalives():
"""Keepalive values already set, e.g. from DATABASE_URL, are not overridden."""
configuration = make_configuration(
DATABASES={
"default": {
"ENGINE": "django.db.backends.postgresql",
"OPTIONS": {"keepalives_idle": "30", "keepalives": "0"},
}
}
)
configuration._configure_database_pool()
options = configuration.DATABASES["default"]["OPTIONS"]
assert options["keepalives_idle"] == "30"
assert options["keepalives"] == "0"
assert options["keepalives_interval"] == 10
@pytest.mark.parametrize("health_checks", [None, False])
def test_database_pool_enabled_forces_health_checks(health_checks):
"""Pooled connections are checked on checkout, even if DATABASE_URL said no."""
database = {"ENGINE": "django.db.backends.postgresql"}
if health_checks is not None:
database["CONN_HEALTH_CHECKS"] = health_checks
configuration = make_configuration(DATABASES={"default": database})
configuration._configure_database_pool()
assert configuration.DATABASES["default"]["CONN_HEALTH_CHECKS"] is True
def test_database_pool_enabled_without_options():
"""A database without OPTIONS, as built from DATABASE_URL, gets them created."""
configuration = make_configuration(
DATABASES={"default": {"ENGINE": "django.db.backends.postgresql_psycopg2"}}
)
configuration._configure_database_pool()
assert configuration.DATABASES["default"]["OPTIONS"]["pool"]["max_size"] == 3
@pytest.mark.parametrize(
"overrides,match",
[
(
{"DATABASES": {"default": {"ENGINE": "django.db.backends.sqlite3"}}},
"requires the django.db.backends.postgresql engine",
),
(
{
"DATABASES": {
"default": {
"ENGINE": "django.db.backends.postgresql",
"CONN_MAX_AGE": 60,
}
}
},
"cannot be combined with persistent connections",
),
(
{"DB_PSYCOPG_POOL_MIN_SIZE": 6},
"MIN_SIZE cannot be greater than",
),
({"DB_PSYCOPG_POOL_TIMEOUT": 0.0}, "must be positive"),
({"DB_PSYCOPG_POOL_MAX_IDLE": -1.0}, "must be positive"),
],
)
def test_database_pool_invalid_configuration(overrides, match):
"""An invalid pool setup fails at boot."""
configuration = make_configuration(**overrides)
with pytest.raises(ImproperlyConfigured, match=match):
configuration._configure_database_pool()
+3 -12
View File
@@ -17,16 +17,7 @@ def sub_validator(value):
``isprintable()`` is false exactly for those control characters, while
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():
raise ValidationError(_(msg))
raise ValidationError(
_("Enter a valid sub. This value should be printable ASCII only.")
)
+64 -15
View File
@@ -17,6 +17,7 @@ import warnings
from os import path
from socket import gethostbyname, gethostname
from django.core.exceptions import ImproperlyConfigured
from django.utils.translation import gettext_lazy as _
import dj_database_url
@@ -139,7 +140,7 @@ class Base(Configuration):
)
else {
"ENGINE": values.Value(
"django.db.backends.postgresql_psycopg2",
"django.db.backends.postgresql",
environ_name="DB_ENGINE",
environ_prefix=None,
),
@@ -154,6 +155,21 @@ class Base(Configuration):
"PORT": values.Value(5432, environ_name="DB_PORT", environ_prefix=None),
}
}
DB_PSYCOPG_POOL_ENABLED = values.BooleanValue(
False, environ_name="DB_PSYCOPG_POOL_ENABLED", environ_prefix=None
)
DB_PSYCOPG_POOL_MIN_SIZE = values.PositiveIntegerValue(
1, environ_name="DB_PSYCOPG_POOL_MIN_SIZE", environ_prefix=None
)
DB_PSYCOPG_POOL_MAX_SIZE = values.PositiveIntegerValue(
3, environ_name="DB_PSYCOPG_POOL_MAX_SIZE", environ_prefix=None
)
DB_PSYCOPG_POOL_TIMEOUT = values.FloatValue(
30.0, environ_name="DB_PSYCOPG_POOL_TIMEOUT", environ_prefix=None
)
DB_PSYCOPG_POOL_MAX_IDLE = values.FloatValue(
600.0, environ_name="DB_PSYCOPG_POOL_MAX_IDLE", environ_prefix=None
)
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
# Static files (CSS, JavaScript, Images)
@@ -709,20 +725,6 @@ class Base(Configuration):
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
LIVEKIT_CONFIGURATION = {
"api_key": SecretFileValue(environ_name="LIVEKIT_API_KEY", environ_prefix=None),
@@ -1460,6 +1462,52 @@ class Base(Configuration):
stacklevel=2,
)
@classmethod
def _configure_database_pool(cls):
"""Inject the psycopg pool options into the default database when enabled."""
if not cls.DB_PSYCOPG_POOL_ENABLED:
return
database = cls.DATABASES["default"]
if database.get("ENGINE") not in (
"django.db.backends.postgresql",
"django.db.backends.postgresql_psycopg2",
):
raise ImproperlyConfigured(
"DB_PSYCOPG_POOL_ENABLED requires the django.db.backends.postgresql "
f"engine, got {database.get('ENGINE')!r}."
)
if database.get("CONN_MAX_AGE"):
raise ImproperlyConfigured(
"DB_PSYCOPG_POOL_ENABLED cannot be combined with persistent "
"connections; set CONN_MAX_AGE to 0."
)
if cls.DB_PSYCOPG_POOL_MIN_SIZE > cls.DB_PSYCOPG_POOL_MAX_SIZE:
raise ImproperlyConfigured(
"DB_PSYCOPG_POOL_MIN_SIZE cannot be greater than "
"DB_PSYCOPG_POOL_MAX_SIZE."
)
if cls.DB_PSYCOPG_POOL_TIMEOUT <= 0 or cls.DB_PSYCOPG_POOL_MAX_IDLE <= 0:
raise ImproperlyConfigured(
"DB_PSYCOPG_POOL_TIMEOUT and DB_PSYCOPG_POOL_MAX_IDLE must be positive."
)
database["OPTIONS"] = {
"keepalives_idle": 60,
"keepalives_interval": 10,
"keepalives_count": 5,
**database.get("OPTIONS", {}),
"pool": {
"min_size": cls.DB_PSYCOPG_POOL_MIN_SIZE,
"max_size": cls.DB_PSYCOPG_POOL_MAX_SIZE,
"timeout": cls.DB_PSYCOPG_POOL_TIMEOUT,
"max_idle": cls.DB_PSYCOPG_POOL_MAX_IDLE,
},
}
# The pool does not watch idle connections: check each one on checkout
# so a broken connection is replaced instead of failing the request.
database["CONN_HEALTH_CHECKS"] = True
@classmethod
def post_setup(cls):
"""Post setup configuration.
@@ -1474,6 +1522,7 @@ class Base(Configuration):
)
cls._check_recording_encoding_maps()
cls._configure_database_pool()
if (
cls.SUMMARY_SERVICE_VERSION == 1
+1 -2
View File
@@ -49,10 +49,9 @@ dependencies = [
"gunicorn==26.2.0",
"jsonschema==4.26.0",
"markdown==3.10.3",
"menshen-client==0.2.0",
"nested-multipart-parser==1.6.0",
"posthog==7.44.0",
"psycopg[binary]==3.3.4",
"psycopg[binary,pool]==3.3.4",
"pydantic==2.13.4",
"PyJWT==2.14.0",
"python-frontmatter==1.3.0",
+18 -17
View File
@@ -1,5 +1,5 @@
version = 1
revision = 5
revision = 3
requires-python = ">=3.13"
[[package]]
@@ -1327,12 +1327,11 @@ dependencies = [
{ name = "jsonschema" },
{ name = "livekit-api" },
{ name = "markdown" },
{ name = "menshen-client" },
{ name = "mozilla-django-oidc" },
{ name = "nested-multipart-parser" },
{ name = "phonenumbers" },
{ name = "posthog" },
{ name = "psycopg", extra = ["binary"] },
{ name = "psycopg", extra = ["binary", "pool"] },
{ name = "pydantic" },
{ name = "pyjwt" },
{ name = "python-frontmatter" },
@@ -1393,12 +1392,11 @@ requires-dist = [
{ name = "jsonschema", specifier = "==4.26.0" },
{ name = "livekit-api", specifier = "==1.2.0" },
{ name = "markdown", specifier = "==3.10.3" },
{ name = "menshen-client", specifier = "==0.2.0" },
{ name = "mozilla-django-oidc", specifier = "==5.0.2" },
{ name = "nested-multipart-parser", specifier = "==1.6.0" },
{ name = "phonenumbers", specifier = "==9.0.37" },
{ name = "posthog", specifier = "==7.44.0" },
{ name = "psycopg", extras = ["binary"], specifier = "==3.3.4" },
{ name = "psycopg", extras = ["binary", "pool"], specifier = "==3.3.4" },
{ name = "pydantic", specifier = "==2.13.4" },
{ name = "pyjwt", specifier = "==2.14.0" },
{ name = "python-frontmatter", specifier = "==1.3.0" },
@@ -1430,18 +1428,6 @@ dev = [
{ 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]]
name = "mozilla-django-oidc"
version = "5.0.2"
@@ -1889,6 +1875,9 @@ wheels = [
binary = [
{ name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
]
pool = [
{ name = "psycopg-pool" },
]
[[package]]
name = "psycopg-binary"
@@ -1919,6 +1908,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" },
]
[[package]]
name = "psycopg-pool"
version = "3.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/74/5e/c0664b968b102ff68b811d999c728546c48d5c1eec03e3bbaf88c0cb4472/psycopg_pool-3.3.3.tar.gz", hash = "sha256:df87b5d9d0ad7db37f6cdad4fa8ce113d250f5997f6db38e9a99192fb67f9e1d", size = 32006, upload-time = "2026-09-22T15:53:24.947Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/b4/452c6607a0f479465cd8a9b0d9956919fcb150050c1f83f9f11e6b8ee8dc/psycopg_pool-3.3.3-py3-none-any.whl", hash = "sha256:9b9cd6a4fcec47a410f7e82d408540e7f77b478509e91b44c1a5457a13e5ff37", size = 40304, upload-time = "2026-09-22T15:53:23.712Z" },
]
[[package]]
name = "ptyprocess"
version = "0.7.0"