Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b3b4b9c28 | |||
| b564044e70 | |||
| 4717143251 | |||
| 805e983749 | |||
| 6dfafb7f67 |
+7
-5
@@ -24,13 +24,15 @@ data
|
||||
.cache
|
||||
.circleci
|
||||
.git
|
||||
.vscode
|
||||
.iml
|
||||
.idea
|
||||
db.sqlite3
|
||||
.mypy_cache
|
||||
.pylint.d
|
||||
.pytest_cache
|
||||
|
||||
**/.idea
|
||||
**/.vscode
|
||||
**/.pytest_cache
|
||||
**/.mypy_cache
|
||||
**/.ruff_cache
|
||||
|
||||
# Frontend
|
||||
node_modules
|
||||
**/node_modules
|
||||
|
||||
@@ -8,11 +8,14 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.10.0] - 2026-03-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🩹(frontend) remove incorrect reference to ProConnect on the prejoin #1080
|
||||
- ✨(frontend) add Ctrl+Shift+/ to open shortcuts settings #1050
|
||||
- ♿(frontend) announce selected state to screen readers #1081
|
||||
- 💄(frontend) truncate long names with ellipsis in reaction overlay #1099
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.9.0"
|
||||
version = "1.10.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.3.10",
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
from rest_framework import permissions
|
||||
|
||||
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
|
||||
|
||||
from ..models import RoleChoices
|
||||
|
||||
ACTION_FOR_METHOD_TO_PERMISSION = {
|
||||
@@ -47,27 +45,11 @@ class RoomPermissions(permissions.BasePermission):
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Only allow authenticated users for unsafe methods.
|
||||
|
||||
Room creation additionally requires the can_create entitlement.
|
||||
Fail-closed: denies creation when the entitlements service is unavailable.
|
||||
"""
|
||||
"""Only allow authenticated users for unsafe methods."""
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
|
||||
if not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
if view.action == "create":
|
||||
try:
|
||||
entitlements = get_user_entitlements(
|
||||
request.user.sub, request.user.email
|
||||
)
|
||||
return entitlements.get("can_create", False)
|
||||
except EntitlementsUnavailableError:
|
||||
return False
|
||||
|
||||
return True
|
||||
return request.user.is_authenticated
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Object permissions are only given to administrators of the room."""
|
||||
|
||||
@@ -14,7 +14,6 @@ from rest_framework.exceptions import PermissionDenied
|
||||
from timezone_field.rest_framework import TimeZoneSerializerField
|
||||
|
||||
from core import models, utils
|
||||
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
@@ -28,25 +27,6 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
||||
|
||||
|
||||
class UserMeSerializer(UserSerializer):
|
||||
"""Serialize users for me endpoint."""
|
||||
|
||||
can_create = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = [*UserSerializer.Meta.fields, "can_create"]
|
||||
read_only_fields = [*UserSerializer.Meta.read_only_fields, "can_create"]
|
||||
|
||||
def get_can_create(self, user) -> bool:
|
||||
"""Check entitlements for the current user."""
|
||||
try:
|
||||
entitlements = get_user_entitlements(user.sub, user.email)
|
||||
return entitlements.get("can_create", False)
|
||||
except EntitlementsUnavailableError:
|
||||
return False
|
||||
|
||||
|
||||
class ResourceAccessSerializerMixin:
|
||||
"""
|
||||
A serializer mixin to share controlling that the logged-in user submitting a room access object
|
||||
|
||||
@@ -187,7 +187,7 @@ class UserViewSet(
|
||||
"""
|
||||
context = {"request": request}
|
||||
return drf_response.Response(
|
||||
serializers.UserMeSerializer(request.user, context=context).data
|
||||
self.serializer_class(request.user, context=context).data
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Authentication Backends for the Meet core app."""
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
|
||||
@@ -11,7 +10,6 @@ from lasuite.oidc_login.backends import (
|
||||
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
|
||||
)
|
||||
|
||||
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
|
||||
from core.models import User
|
||||
from core.services.marketing import (
|
||||
ContactCreationError,
|
||||
@@ -19,8 +17,6 @@ from core.services.marketing import (
|
||||
get_marketing_service,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
||||
@@ -63,21 +59,6 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||
if is_new_user and email and settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
|
||||
self.signup_to_marketing_email(email)
|
||||
|
||||
# Warm the entitlements cache on login (force_refresh)
|
||||
try:
|
||||
get_user_entitlements(
|
||||
user_sub=user.sub,
|
||||
user_email=user.email,
|
||||
user_info=claims,
|
||||
force_refresh=True,
|
||||
)
|
||||
except EntitlementsUnavailableError:
|
||||
email_domain = user.email.split("@")[-1] if "@" in user.email else "?"
|
||||
logger.warning(
|
||||
"Entitlements unavailable for user@%s during login",
|
||||
email_domain,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def signup_to_marketing_email(email):
|
||||
"""Pragmatic approach to newsletter signup during authentication flow.
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
"""Entitlements service layer."""
|
||||
|
||||
from core.entitlements.factory import get_entitlements_backend
|
||||
|
||||
|
||||
class EntitlementsUnavailableError(Exception):
|
||||
"""Raised when the entitlements backend cannot be reached or returns an error."""
|
||||
|
||||
|
||||
def get_user_entitlements(user_sub, user_email, user_info=None, force_refresh=False):
|
||||
"""Get user entitlements, delegating to the configured backend.
|
||||
|
||||
Args:
|
||||
user_sub: The user's OIDC subject identifier.
|
||||
user_email: The user's email address.
|
||||
user_info: The full OIDC user_info dict (forwarded to backend).
|
||||
force_refresh: If True, bypass backend cache and fetch fresh data.
|
||||
|
||||
Returns:
|
||||
dict: {"can_create": bool}
|
||||
|
||||
Raises:
|
||||
EntitlementsUnavailableError: If the backend cannot be reached
|
||||
and no cache exists.
|
||||
"""
|
||||
backend = get_entitlements_backend()
|
||||
return backend.get_user_entitlements(
|
||||
user_sub, user_email, user_info=user_info, force_refresh=force_refresh
|
||||
)
|
||||
@@ -1,27 +0,0 @@
|
||||
"""Abstract base class for entitlements backends."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class EntitlementsBackend(ABC):
|
||||
"""Abstract base class that defines the interface for entitlements backends."""
|
||||
|
||||
@abstractmethod
|
||||
def get_user_entitlements(
|
||||
self, user_sub, user_email, user_info=None, force_refresh=False
|
||||
):
|
||||
"""Fetch user entitlements.
|
||||
|
||||
Args:
|
||||
user_sub: The user's OIDC subject identifier.
|
||||
user_email: The user's email address.
|
||||
user_info: The full OIDC user_info dict (backends may
|
||||
extract claims from it).
|
||||
force_refresh: If True, bypass any cache and fetch fresh data.
|
||||
|
||||
Returns:
|
||||
dict: {"can_create": bool}
|
||||
|
||||
Raises:
|
||||
EntitlementsUnavailableError: If the backend cannot be reached.
|
||||
"""
|
||||
@@ -1,120 +0,0 @@
|
||||
"""DeployCenter (Espace Operateur) entitlements backend."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
import requests
|
||||
|
||||
from core.entitlements import EntitlementsUnavailableError
|
||||
from core.entitlements.backends.base import EntitlementsBackend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DeployCenterEntitlementsBackend(EntitlementsBackend):
|
||||
"""Backend that fetches entitlements from the DeployCenter API.
|
||||
|
||||
Args:
|
||||
base_url: Full URL of the entitlements endpoint
|
||||
(e.g. "https://dc.example.com/api/v1.0/entitlements/").
|
||||
service_id: The service identifier in DeployCenter.
|
||||
api_key: API key for X-Service-Auth header.
|
||||
timeout: HTTP request timeout in seconds.
|
||||
oidc_claims: List of OIDC claim names to extract from user_info
|
||||
and forward as query params (e.g. ["siret"]).
|
||||
"""
|
||||
|
||||
def __init__( # pylint: disable=too-many-arguments
|
||||
self,
|
||||
base_url,
|
||||
service_id,
|
||||
api_key,
|
||||
*,
|
||||
timeout=10,
|
||||
oidc_claims=None,
|
||||
):
|
||||
self.base_url = base_url
|
||||
self.service_id = service_id
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
self.oidc_claims = oidc_claims or []
|
||||
|
||||
def _cache_key(self, user_sub):
|
||||
return f"entitlements:user:{user_sub}"
|
||||
|
||||
def _make_request(self, user_email, user_info=None):
|
||||
"""Make a request to the DeployCenter entitlements API.
|
||||
|
||||
Returns:
|
||||
dict | None: The response data, or None on failure.
|
||||
"""
|
||||
params = {
|
||||
"service_id": self.service_id,
|
||||
"account_type": "user",
|
||||
"account_email": user_email,
|
||||
}
|
||||
|
||||
# Forward configured OIDC claims as query params
|
||||
if user_info:
|
||||
for claim in self.oidc_claims:
|
||||
if claim in user_info:
|
||||
params[claim] = user_info[claim]
|
||||
|
||||
headers = {
|
||||
"X-Service-Auth": f"Bearer {self.api_key}",
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
self.base_url,
|
||||
params=params,
|
||||
headers=headers,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except (requests.RequestException, ValueError):
|
||||
email_domain = user_email.split("@")[-1] if "@" in user_email else "?"
|
||||
logger.warning(
|
||||
"DeployCenter entitlements request failed for user@%s",
|
||||
email_domain,
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
def get_user_entitlements(
|
||||
self, user_sub, user_email, user_info=None, force_refresh=False
|
||||
):
|
||||
"""Fetch user entitlements from DeployCenter with caching.
|
||||
|
||||
On cache miss or force_refresh: fetches from the API.
|
||||
On API failure: falls back to stale cache if available,
|
||||
otherwise raises EntitlementsUnavailableError.
|
||||
"""
|
||||
cache_key = self._cache_key(user_sub)
|
||||
|
||||
if not force_refresh:
|
||||
cached = cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
data = self._make_request(user_email, user_info=user_info)
|
||||
|
||||
if data is None:
|
||||
# API failed — try stale cache as fallback
|
||||
cached = cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
raise EntitlementsUnavailableError(
|
||||
"Failed to fetch user entitlements from DeployCenter"
|
||||
)
|
||||
|
||||
entitlements = data.get("entitlements", {})
|
||||
result = {
|
||||
"can_create": entitlements.get("can_create", False),
|
||||
}
|
||||
|
||||
cache.set(cache_key, result, settings.ENTITLEMENTS_CACHE_TIMEOUT)
|
||||
return result
|
||||
@@ -1,12 +0,0 @@
|
||||
"""Local entitlements backend for development and testing."""
|
||||
|
||||
from core.entitlements.backends.base import EntitlementsBackend
|
||||
|
||||
|
||||
class LocalEntitlementsBackend(EntitlementsBackend):
|
||||
"""Local backend that always grants access."""
|
||||
|
||||
def get_user_entitlements(
|
||||
self, user_sub, user_email, user_info=None, force_refresh=False
|
||||
):
|
||||
return {"can_create": True}
|
||||
@@ -1,13 +0,0 @@
|
||||
"""Factory for creating entitlements backend instances."""
|
||||
|
||||
import functools
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
|
||||
@functools.cache
|
||||
def get_entitlements_backend():
|
||||
"""Return a singleton instance of the configured entitlements backend."""
|
||||
backend_class = import_string(settings.ENTITLEMENTS_BACKEND)
|
||||
return backend_class(**settings.ENTITLEMENTS_BACKEND_PARAMETERS)
|
||||
@@ -125,7 +125,6 @@ def test_api_users_retrieve_me_authenticated(settings):
|
||||
"short_name": user.short_name,
|
||||
"language": user.language,
|
||||
"timezone": "UTC",
|
||||
"can_create": True,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,504 +0,0 @@
|
||||
"""Tests for the entitlements module."""
|
||||
|
||||
# pylint: disable=redefined-outer-name
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
import responses
|
||||
from rest_framework.status import HTTP_201_CREATED, HTTP_403_FORBIDDEN
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from django.core.cache import cache as django_cache
|
||||
|
||||
from core import factories
|
||||
from core.api.serializers import UserMeSerializer
|
||||
from core.authentication.backends import OIDCAuthenticationBackend
|
||||
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
|
||||
from core.entitlements.backends.deploycenter import DeployCenterEntitlementsBackend
|
||||
from core.entitlements.backends.local import LocalEntitlementsBackend
|
||||
from core.entitlements.factory import get_entitlements_backend
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
DC_URL = "https://deploy.example.com/api/v1.0/entitlements/"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_cache():
|
||||
"""Clear Django cache between tests to prevent entitlements cache bleed."""
|
||||
django_cache.clear()
|
||||
|
||||
|
||||
# -- LocalEntitlementsBackend --
|
||||
|
||||
|
||||
def test_local_backend_always_grants_access():
|
||||
"""The local backend should always return can_create=True."""
|
||||
backend = LocalEntitlementsBackend()
|
||||
result = backend.get_user_entitlements("sub-123", "user@example.com")
|
||||
assert result == {"can_create": True}
|
||||
|
||||
|
||||
def test_local_backend_ignores_parameters():
|
||||
"""The local backend should work regardless of parameters passed."""
|
||||
backend = LocalEntitlementsBackend()
|
||||
result = backend.get_user_entitlements(
|
||||
"sub-123",
|
||||
"user@example.com",
|
||||
user_info={"some": "claim"},
|
||||
force_refresh=True,
|
||||
)
|
||||
assert result == {"can_create": True}
|
||||
|
||||
|
||||
# -- Factory --
|
||||
|
||||
|
||||
@override_settings(
|
||||
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
|
||||
ENTITLEMENTS_BACKEND_PARAMETERS={},
|
||||
)
|
||||
def test_factory_returns_local_backend():
|
||||
"""The factory should instantiate the configured backend."""
|
||||
get_entitlements_backend.cache_clear()
|
||||
backend = get_entitlements_backend()
|
||||
assert isinstance(backend, LocalEntitlementsBackend)
|
||||
get_entitlements_backend.cache_clear()
|
||||
|
||||
|
||||
@override_settings(
|
||||
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
|
||||
ENTITLEMENTS_BACKEND_PARAMETERS={},
|
||||
)
|
||||
def test_factory_singleton():
|
||||
"""The factory should return the same instance on repeated calls."""
|
||||
get_entitlements_backend.cache_clear()
|
||||
backend1 = get_entitlements_backend()
|
||||
backend2 = get_entitlements_backend()
|
||||
assert backend1 is backend2
|
||||
get_entitlements_backend.cache_clear()
|
||||
|
||||
|
||||
# -- get_user_entitlements public API --
|
||||
|
||||
|
||||
@override_settings(
|
||||
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
|
||||
ENTITLEMENTS_BACKEND_PARAMETERS={},
|
||||
)
|
||||
def test_get_user_entitlements_with_local_backend():
|
||||
"""The public API should delegate to the configured backend."""
|
||||
get_entitlements_backend.cache_clear()
|
||||
result = get_user_entitlements("sub-123", "user@example.com")
|
||||
assert result["can_create"] is True
|
||||
get_entitlements_backend.cache_clear()
|
||||
|
||||
|
||||
# -- DeployCenterEntitlementsBackend --
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_deploycenter_backend_grants_access():
|
||||
"""DeployCenter backend should return can_create from API response."""
|
||||
responses.add(
|
||||
responses.GET,
|
||||
DC_URL,
|
||||
json={"entitlements": {"can_create": True}},
|
||||
status=200,
|
||||
)
|
||||
|
||||
backend = DeployCenterEntitlementsBackend(
|
||||
base_url=DC_URL,
|
||||
service_id="meet",
|
||||
api_key="test-key",
|
||||
)
|
||||
result = backend.get_user_entitlements("sub-123", "user@example.com")
|
||||
assert result == {"can_create": True}
|
||||
|
||||
# Verify request was made with correct params and header
|
||||
assert len(responses.calls) == 1
|
||||
request = responses.calls[0].request
|
||||
assert "service_id=meet" in request.url
|
||||
assert "account_email=user%40example.com" in request.url
|
||||
assert request.headers["X-Service-Auth"] == "Bearer test-key"
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_deploycenter_backend_denies_access():
|
||||
"""DeployCenter backend should return can_create=False when API says so."""
|
||||
responses.add(
|
||||
responses.GET,
|
||||
DC_URL,
|
||||
json={"entitlements": {"can_create": False}},
|
||||
status=200,
|
||||
)
|
||||
|
||||
backend = DeployCenterEntitlementsBackend(
|
||||
base_url=DC_URL,
|
||||
service_id="meet",
|
||||
api_key="test-key",
|
||||
)
|
||||
result = backend.get_user_entitlements("sub-123", "user@example.com")
|
||||
assert result == {"can_create": False}
|
||||
|
||||
|
||||
@responses.activate
|
||||
@override_settings(ENTITLEMENTS_CACHE_TIMEOUT=300)
|
||||
def test_deploycenter_backend_uses_cache():
|
||||
"""DeployCenter should use cached results when not force_refresh."""
|
||||
responses.add(
|
||||
responses.GET,
|
||||
DC_URL,
|
||||
json={"entitlements": {"can_create": True}},
|
||||
status=200,
|
||||
)
|
||||
|
||||
backend = DeployCenterEntitlementsBackend(
|
||||
base_url=DC_URL,
|
||||
service_id="meet",
|
||||
api_key="test-key",
|
||||
)
|
||||
|
||||
# First call hits the API
|
||||
result1 = backend.get_user_entitlements("sub-123", "user@example.com")
|
||||
assert result1 == {"can_create": True}
|
||||
assert len(responses.calls) == 1
|
||||
|
||||
# Second call should use cache
|
||||
result2 = backend.get_user_entitlements("sub-123", "user@example.com")
|
||||
assert result2 == {"can_create": True}
|
||||
assert len(responses.calls) == 1 # No additional API call
|
||||
|
||||
|
||||
@responses.activate
|
||||
@override_settings(ENTITLEMENTS_CACHE_TIMEOUT=300)
|
||||
def test_deploycenter_backend_force_refresh_bypasses_cache():
|
||||
"""force_refresh=True should bypass cache and hit the API."""
|
||||
responses.add(
|
||||
responses.GET,
|
||||
DC_URL,
|
||||
json={"entitlements": {"can_create": True}},
|
||||
status=200,
|
||||
)
|
||||
responses.add(
|
||||
responses.GET,
|
||||
DC_URL,
|
||||
json={"entitlements": {"can_create": False}},
|
||||
status=200,
|
||||
)
|
||||
|
||||
backend = DeployCenterEntitlementsBackend(
|
||||
base_url=DC_URL,
|
||||
service_id="meet",
|
||||
api_key="test-key",
|
||||
)
|
||||
|
||||
result1 = backend.get_user_entitlements("sub-123", "user@example.com")
|
||||
assert result1["can_create"] is True
|
||||
|
||||
result2 = backend.get_user_entitlements(
|
||||
"sub-123", "user@example.com", force_refresh=True
|
||||
)
|
||||
assert result2["can_create"] is False
|
||||
assert len(responses.calls) == 2
|
||||
|
||||
|
||||
@responses.activate
|
||||
@override_settings(ENTITLEMENTS_CACHE_TIMEOUT=300)
|
||||
def test_deploycenter_backend_fallback_to_stale_cache():
|
||||
"""When API fails, should return stale cached value if available."""
|
||||
responses.add(
|
||||
responses.GET,
|
||||
DC_URL,
|
||||
json={"entitlements": {"can_create": True}},
|
||||
status=200,
|
||||
)
|
||||
|
||||
backend = DeployCenterEntitlementsBackend(
|
||||
base_url=DC_URL,
|
||||
service_id="meet",
|
||||
api_key="test-key",
|
||||
)
|
||||
|
||||
# Populate cache
|
||||
backend.get_user_entitlements("sub-123", "user@example.com")
|
||||
|
||||
# Now API fails
|
||||
responses.replace(
|
||||
responses.GET,
|
||||
DC_URL,
|
||||
body=requests.ConnectionError("Connection error"),
|
||||
)
|
||||
|
||||
# force_refresh to hit API, but should fall back to cache
|
||||
result = backend.get_user_entitlements(
|
||||
"sub-123", "user@example.com", force_refresh=True
|
||||
)
|
||||
assert result == {"can_create": True}
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_deploycenter_backend_raises_when_no_cache():
|
||||
"""When API fails and no cache exists, should raise."""
|
||||
responses.add(
|
||||
responses.GET,
|
||||
DC_URL,
|
||||
body=requests.ConnectionError("Connection error"),
|
||||
)
|
||||
|
||||
backend = DeployCenterEntitlementsBackend(
|
||||
base_url=DC_URL,
|
||||
service_id="meet",
|
||||
api_key="test-key",
|
||||
)
|
||||
|
||||
with pytest.raises(EntitlementsUnavailableError):
|
||||
backend.get_user_entitlements("sub-123", "user@example.com")
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_deploycenter_backend_sends_oidc_claims():
|
||||
"""DeployCenter should forward configured OIDC claims."""
|
||||
responses.add(
|
||||
responses.GET,
|
||||
DC_URL,
|
||||
json={"entitlements": {"can_create": True}},
|
||||
status=200,
|
||||
)
|
||||
|
||||
backend = DeployCenterEntitlementsBackend(
|
||||
base_url=DC_URL,
|
||||
service_id="meet",
|
||||
api_key="test-key",
|
||||
oidc_claims=["organization"],
|
||||
)
|
||||
|
||||
backend.get_user_entitlements(
|
||||
"sub-123",
|
||||
"user@example.com",
|
||||
user_info={"organization": "org-42", "other": "ignored"},
|
||||
)
|
||||
|
||||
request = responses.calls[0].request
|
||||
assert "organization=org-42" in request.url
|
||||
assert "other" not in request.url
|
||||
|
||||
|
||||
# -- Auth backend integration --
|
||||
|
||||
|
||||
def test_auth_backend_warms_cache_on_login():
|
||||
"""post_get_or_create_user should call get_user_entitlements with force_refresh."""
|
||||
user = factories.UserFactory()
|
||||
backend = OIDCAuthenticationBackend()
|
||||
|
||||
with mock.patch(
|
||||
"core.authentication.backends.get_user_entitlements",
|
||||
return_value={"can_create": True},
|
||||
) as mock_ent:
|
||||
backend.post_get_or_create_user(
|
||||
user, {"email": user.email, "sub": "x"}, is_new_user=False
|
||||
)
|
||||
mock_ent.assert_called_once_with(
|
||||
user_sub=user.sub,
|
||||
user_email=user.email,
|
||||
user_info={"email": user.email, "sub": "x"},
|
||||
force_refresh=True,
|
||||
)
|
||||
|
||||
|
||||
def test_auth_backend_login_succeeds_when_access_denied():
|
||||
"""Login should succeed even when can_create is False (gated in frontend)."""
|
||||
user = factories.UserFactory()
|
||||
backend = OIDCAuthenticationBackend()
|
||||
|
||||
with mock.patch(
|
||||
"core.authentication.backends.get_user_entitlements",
|
||||
return_value={"can_create": False},
|
||||
):
|
||||
# Should not raise — user logs in, frontend gates access
|
||||
backend.post_get_or_create_user(
|
||||
user, {"email": user.email}, is_new_user=False
|
||||
)
|
||||
|
||||
|
||||
def test_auth_backend_login_succeeds_when_entitlements_unavailable():
|
||||
"""Login should succeed when entitlements service is unavailable."""
|
||||
user = factories.UserFactory()
|
||||
backend = OIDCAuthenticationBackend()
|
||||
|
||||
with mock.patch(
|
||||
"core.authentication.backends.get_user_entitlements",
|
||||
side_effect=EntitlementsUnavailableError("unavailable"),
|
||||
):
|
||||
# Should not raise
|
||||
backend.post_get_or_create_user(
|
||||
user, {"email": user.email}, is_new_user=False
|
||||
)
|
||||
|
||||
|
||||
# -- UserMeSerializer (can_create field) --
|
||||
|
||||
|
||||
def test_user_me_serializer_includes_can_create_true():
|
||||
"""UserMeSerializer should include can_create=True when entitled."""
|
||||
user = factories.UserFactory()
|
||||
with mock.patch(
|
||||
"core.api.serializers.get_user_entitlements",
|
||||
return_value={"can_create": True},
|
||||
):
|
||||
data = UserMeSerializer(user).data
|
||||
assert data["can_create"] is True
|
||||
|
||||
|
||||
def test_user_me_serializer_includes_can_create_false():
|
||||
"""UserMeSerializer should include can_create=False when not entitled."""
|
||||
user = factories.UserFactory()
|
||||
with mock.patch(
|
||||
"core.api.serializers.get_user_entitlements",
|
||||
return_value={"can_create": False},
|
||||
):
|
||||
data = UserMeSerializer(user).data
|
||||
assert data["can_create"] is False
|
||||
|
||||
|
||||
def test_user_me_serializer_can_create_fail_closed():
|
||||
"""UserMeSerializer should return can_create=False when entitlements unavailable."""
|
||||
user = factories.UserFactory()
|
||||
with mock.patch(
|
||||
"core.api.serializers.get_user_entitlements",
|
||||
side_effect=EntitlementsUnavailableError("unavailable"),
|
||||
):
|
||||
data = UserMeSerializer(user).data
|
||||
assert data["can_create"] is False
|
||||
|
||||
|
||||
# -- /users/me/ endpoint integration --
|
||||
|
||||
|
||||
def test_api_users_me_includes_can_create():
|
||||
"""GET /users/me/ should include can_create in the response."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "can_create" in response.json()
|
||||
assert response.json()["can_create"] is True
|
||||
|
||||
|
||||
def test_api_users_me_can_create_false():
|
||||
"""GET /users/me/ should return can_create=False when not entitled."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
with mock.patch(
|
||||
"core.api.serializers.get_user_entitlements",
|
||||
return_value={"can_create": False},
|
||||
):
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["can_create"] is False
|
||||
|
||||
|
||||
# -- Room creation entitlements enforcement --
|
||||
|
||||
|
||||
def test_room_creation_blocked_when_not_entitled():
|
||||
"""Room creation should return 403 when user has can_create=False."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
with mock.patch(
|
||||
"core.api.permissions.get_user_entitlements",
|
||||
return_value={"can_create": False},
|
||||
):
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/",
|
||||
data={"name": "test-room"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_room_creation_blocked_when_entitlements_unavailable():
|
||||
"""Room creation should return 403 when entitlements service
|
||||
is unavailable (fail-closed)."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
with mock.patch(
|
||||
"core.api.permissions.get_user_entitlements",
|
||||
side_effect=EntitlementsUnavailableError("unavailable"),
|
||||
):
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/",
|
||||
data={"name": "test-room"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_room_creation_allowed_when_entitled():
|
||||
"""Room creation should succeed when user has can_create=True."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
with mock.patch(
|
||||
"core.api.permissions.get_user_entitlements",
|
||||
return_value={"can_create": True},
|
||||
):
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/",
|
||||
data={"name": "test-room"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_201_CREATED
|
||||
|
||||
|
||||
# -- Non-create room actions are NOT gated by entitlements --
|
||||
|
||||
|
||||
def test_room_retrieve_allowed_when_not_entitled():
|
||||
"""Room retrieval should work even when user has can_create=False."""
|
||||
user = factories.UserFactory()
|
||||
room = factories.RoomFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
with mock.patch(
|
||||
"core.api.permissions.get_user_entitlements",
|
||||
return_value={"can_create": False},
|
||||
):
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_room_list_allowed_when_not_entitled():
|
||||
"""Room listing should work even when user has can_create=False."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
with mock.patch(
|
||||
"core.api.permissions.get_user_entitlements",
|
||||
return_value={"can_create": False},
|
||||
):
|
||||
response = client.get("/api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -706,23 +706,6 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Entitlements
|
||||
ENTITLEMENTS_BACKEND = values.Value(
|
||||
"core.entitlements.backends.local.LocalEntitlementsBackend",
|
||||
environ_name="ENTITLEMENTS_BACKEND",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ENTITLEMENTS_BACKEND_PARAMETERS = values.DictValue(
|
||||
{},
|
||||
environ_name="ENTITLEMENTS_BACKEND_PARAMETERS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ENTITLEMENTS_CACHE_TIMEOUT = values.PositiveIntegerValue(
|
||||
300, # 5 minutes
|
||||
environ_name="ENTITLEMENTS_CACHE_TIMEOUT",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Calendar integrations
|
||||
ROOM_CREATION_CALLBACK_CACHE_TIMEOUT = values.PositiveIntegerValue(
|
||||
600, # 10 minutes
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "1.9.0"
|
||||
version = "1.10.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -40,7 +40,7 @@ dependencies = [
|
||||
"django-storages[s3]==1.14.6",
|
||||
"django-timezone-field>=5.1",
|
||||
"django-pydantic-field==0.5.4",
|
||||
"django==5.2.11",
|
||||
"django==5.2.12",
|
||||
"djangorestframework==3.16.1",
|
||||
"drf_spectacular==0.29.0",
|
||||
"dockerflow==2026.1.26",
|
||||
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/material-symbols-outlined": "5.2.34",
|
||||
"@fontsource/material-icons-outlined": "5.2.6",
|
||||
@@ -1106,9 +1106,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.9",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz",
|
||||
"integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==",
|
||||
"version": "1.19.10",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.10.tgz",
|
||||
"integrity": "sha512-hZ7nOssGqRgyV3FVVQdfi+U4q02uB23bpnYpdvNXkYTRRyWx84b7yf1ans+dnJ/7h41sGL3CeQTfO+ZGxuO+Iw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
|
||||
@@ -7,5 +7,4 @@ export type ApiUser = {
|
||||
last_name: string
|
||||
language: BackendLanguage
|
||||
timezone: string
|
||||
can_create?: boolean
|
||||
}
|
||||
|
||||
@@ -148,8 +148,7 @@ const IntroText = styled('div', {
|
||||
|
||||
export const Home = () => {
|
||||
const { t } = useTranslation('home')
|
||||
const { isLoggedIn, user } = useUser()
|
||||
const canCreate = user?.can_create === true
|
||||
const { isLoggedIn } = useUser()
|
||||
|
||||
const {
|
||||
userChoices: { username },
|
||||
@@ -201,56 +200,45 @@ export const Home = () => {
|
||||
})}
|
||||
>
|
||||
{isLoggedIn ? (
|
||||
canCreate ? (
|
||||
<Menu>
|
||||
<Button variant="primary" data-attr="create-meeting">
|
||||
{t('createMeeting')}
|
||||
</Button>
|
||||
<RACMenu>
|
||||
<MenuItem
|
||||
className={
|
||||
menuRecipe({ icon: true, variant: 'light' }).item
|
||||
}
|
||||
onAction={async () => {
|
||||
const slug = generateRoomId()
|
||||
createRoom({ slug, username }).then((data) =>
|
||||
navigateTo('room', data.slug, {
|
||||
state: { create: true, initialRoomData: data },
|
||||
})
|
||||
)
|
||||
}}
|
||||
data-attr="create-option-instant"
|
||||
>
|
||||
<RiAddLine size={18} />
|
||||
{t('createMenu.instantOption')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className={
|
||||
menuRecipe({ icon: true, variant: 'light' }).item
|
||||
}
|
||||
onAction={() => {
|
||||
const slug = generateRoomId()
|
||||
createRoom({ slug, username }).then((data) =>
|
||||
setLaterRoom(data)
|
||||
)
|
||||
}}
|
||||
data-attr="create-option-later"
|
||||
>
|
||||
<RiLink size={18} />
|
||||
{t('createMenu.laterOption')}
|
||||
</MenuItem>
|
||||
</RACMenu>
|
||||
</Menu>
|
||||
) : (
|
||||
<p
|
||||
className={css({
|
||||
color: 'greyscale.700',
|
||||
fontSize: '0.95rem',
|
||||
})}
|
||||
>
|
||||
{t('noAccess')}
|
||||
</p>
|
||||
)
|
||||
<Menu>
|
||||
<Button variant="primary" data-attr="create-meeting">
|
||||
{t('createMeeting')}
|
||||
</Button>
|
||||
<RACMenu>
|
||||
<MenuItem
|
||||
className={
|
||||
menuRecipe({ icon: true, variant: 'light' }).item
|
||||
}
|
||||
onAction={async () => {
|
||||
const slug = generateRoomId()
|
||||
createRoom({ slug, username }).then((data) =>
|
||||
navigateTo('room', data.slug, {
|
||||
state: { create: true, initialRoomData: data },
|
||||
})
|
||||
)
|
||||
}}
|
||||
data-attr="create-option-instant"
|
||||
>
|
||||
<RiAddLine size={18} />
|
||||
{t('createMenu.instantOption')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className={
|
||||
menuRecipe({ icon: true, variant: 'light' }).item
|
||||
}
|
||||
onAction={() => {
|
||||
const slug = generateRoomId()
|
||||
createRoom({ slug, username }).then((data) =>
|
||||
setLaterRoom(data)
|
||||
)
|
||||
}}
|
||||
data-attr="create-option-later"
|
||||
>
|
||||
<RiLink size={18} />
|
||||
{t('createMenu.laterOption')}
|
||||
</MenuItem>
|
||||
</RACMenu>
|
||||
</Menu>
|
||||
) : (
|
||||
<LoginButton proConnectHint={false} />
|
||||
)}
|
||||
|
||||
@@ -102,6 +102,11 @@ export function FloatingReaction({
|
||||
paddingTop: '0.15rem',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
lineHeight: '16px',
|
||||
maxWidth: '12rem',
|
||||
display: 'inline-block',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
})}
|
||||
>
|
||||
{name}
|
||||
|
||||
@@ -10,10 +10,7 @@ const callbackIdHandler = new CallbackIdHandler()
|
||||
const popupWindow = new PopupWindow()
|
||||
|
||||
export const CreatePopup = () => {
|
||||
const { isLoggedIn, user } = useUser({
|
||||
fetchUserOptions: { attemptSilent: false },
|
||||
})
|
||||
const canCreate = user?.can_create === true
|
||||
const { isLoggedIn } = useUser({ fetchUserOptions: { attemptSilent: false } })
|
||||
const { mutateAsync: createRoom } = useCreateRoom()
|
||||
|
||||
const callbackId = useMemo(() => callbackIdHandler.getOrCreate(), [])
|
||||
@@ -58,10 +55,10 @@ export const CreatePopup = () => {
|
||||
console.error('Failed to create meeting room:', error)
|
||||
}
|
||||
}
|
||||
if (isLoggedIn && canCreate && callbackId) {
|
||||
if (isLoggedIn && callbackId) {
|
||||
createMeetingRoom()
|
||||
}
|
||||
}, [isLoggedIn, canCreate, callbackId, createRoom])
|
||||
}, [isLoggedIn, callbackId, createRoom])
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
"joinMeetingTipContent": "Sie können einem Meeting beitreten, indem Sie den vollständigen Link in die Adressleiste Ihres Browsers einfügen.",
|
||||
"joinMeetingTipHeading": "Wussten Sie schon?",
|
||||
"loginToCreateMeeting": "Melden Sie sich an, um ein Meeting zu erstellen",
|
||||
"noAccess": "Sie haben keinen Zugang zur Erstellung von Meetings. Bitte kontaktieren Sie Ihren Administrator.",
|
||||
"moreLinkLabel": "Mehr erfahren – neues Tab",
|
||||
"moreLink": "Mehr erfahren",
|
||||
"moreAbout": "über {{appTitle}}",
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
"joinMeetingTipContent": "You can join a meeting by pasting its full link in the browser's address bar.",
|
||||
"joinMeetingTipHeading": "Did you know?",
|
||||
"loginToCreateMeeting": "Login to create a meeting",
|
||||
"noAccess": "You do not have access to create meetings. Please contact your administrator.",
|
||||
"moreLinkLabel": "Learn more - new tab",
|
||||
"moreLink": "Learn more",
|
||||
"moreAbout": "about {{appTitle}}",
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
"joinMeetingTipContent": "Vous pouvez rejoindre une réunion en copiant directement son lien complet dans la barre d'adresse du navigateur.",
|
||||
"joinMeetingTipHeading": "Astuce",
|
||||
"loginToCreateMeeting": "Connectez-vous pour créer une réunion",
|
||||
"noAccess": "Vous n'avez pas accès à la création de réunions. Veuillez contacter votre administrateur.",
|
||||
"moreLinkLabel": "En savoir plus - nouvelle fenêtre",
|
||||
"moreLink": "En savoir plus",
|
||||
"moreAbout": "sur {{appTitle}}",
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
"joinMeetingTipContent": "U kunt deelnemen aan een vergadering door de volledige link in de adresbalk van de browser te plakken.",
|
||||
"joinMeetingTipHeading": "Wist u dat?",
|
||||
"loginToCreateMeeting": "Log in om een vergadering te maken",
|
||||
"noAccess": "U heeft geen toegang om vergaderingen te maken. Neem contact op met uw beheerder.",
|
||||
"moreLinkLabel": "Meer informatie - nieuw tabblad",
|
||||
"moreLink": "Meer informatie",
|
||||
"moreAbout": "over {{appTitle}}",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mail_mjml",
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@html-to/text-cli": "0.5.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"description": "An util to generate html and text django's templates from mjml templates",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sdk",
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"license": "ISC",
|
||||
"workspaces": [
|
||||
"./library",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "summary"
|
||||
version = "1.9.0"
|
||||
version = "1.10.0"
|
||||
dependencies = [
|
||||
"fastapi[standard]>=0.105.0",
|
||||
"uvicorn>=0.24.0",
|
||||
|
||||
Reference in New Issue
Block a user