From ca98cf5facca5941daec45fe5b7893fc291779d4 Mon Sep 17 00:00:00 2001 From: Sylvain Zimmer Date: Sat, 7 Mar 2026 14:07:37 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(entitlements)=20add=20Entitlements=20?= =?UTF-8?q?system=20with=20pluggable=20backends?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This follows implementations in Drive, Messages and Calendars. This system allows Meet to gate some features for users depending on an authorization server. We provide 2 backends: a local one that always allows room creation, mimicking the current behaviour, and a DeployCenter backend, that fetches a "can_create" flag from a remote API. Future deployment contexts might add new backends, or reuse the API format of the DeployCenter one. --- src/backend/core/api/permissions.py | 22 +- src/backend/core/api/serializers.py | 20 + src/backend/core/api/viewsets.py | 2 +- src/backend/core/authentication/backends.py | 19 + src/backend/core/entitlements/__init__.py | 29 + .../core/entitlements/backends/__init__.py | 0 .../core/entitlements/backends/base.py | 27 + .../entitlements/backends/deploycenter.py | 120 +++++ .../core/entitlements/backends/local.py | 12 + src/backend/core/entitlements/factory.py | 13 + src/backend/core/tests/test_api_users.py | 1 + src/backend/core/tests/test_entitlements.py | 504 ++++++++++++++++++ src/backend/meet/settings.py | 17 + src/frontend/src/features/auth/api/ApiUser.ts | 1 + .../src/features/home/routes/Home.tsx | 92 ++-- .../src/features/sdk/routes/CreatePopup.tsx | 9 +- src/frontend/src/locales/de/home.json | 1 + src/frontend/src/locales/en/home.json | 1 + src/frontend/src/locales/fr/home.json | 1 + src/frontend/src/locales/nl/home.json | 1 + 20 files changed, 846 insertions(+), 46 deletions(-) create mode 100644 src/backend/core/entitlements/__init__.py create mode 100644 src/backend/core/entitlements/backends/__init__.py create mode 100644 src/backend/core/entitlements/backends/base.py create mode 100644 src/backend/core/entitlements/backends/deploycenter.py create mode 100644 src/backend/core/entitlements/backends/local.py create mode 100644 src/backend/core/entitlements/factory.py create mode 100644 src/backend/core/tests/test_entitlements.py diff --git a/src/backend/core/api/permissions.py b/src/backend/core/api/permissions.py index ce1f1934..d09cb7d3 100644 --- a/src/backend/core/api/permissions.py +++ b/src/backend/core/api/permissions.py @@ -2,6 +2,8 @@ from rest_framework import permissions +from core.entitlements import EntitlementsUnavailableError, get_user_entitlements + from ..models import RoleChoices ACTION_FOR_METHOD_TO_PERMISSION = { @@ -45,11 +47,27 @@ class RoomPermissions(permissions.BasePermission): """ def has_permission(self, request, view): - """Only allow authenticated users for unsafe methods.""" + """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. + """ if request.method in permissions.SAFE_METHODS: return True - return request.user.is_authenticated + 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 def has_object_permission(self, request, view, obj): """Object permissions are only given to administrators of the room.""" diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 84092368..533e78eb 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -14,6 +14,7 @@ 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): @@ -27,6 +28,25 @@ 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 diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index f9e76659..eb35fe26 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -187,7 +187,7 @@ class UserViewSet( """ context = {"request": request} return drf_response.Response( - self.serializer_class(request.user, context=context).data + serializers.UserMeSerializer(request.user, context=context).data ) diff --git a/src/backend/core/authentication/backends.py b/src/backend/core/authentication/backends.py index 64a962c9..c63638cb 100644 --- a/src/backend/core/authentication/backends.py +++ b/src/backend/core/authentication/backends.py @@ -1,6 +1,7 @@ """Authentication Backends for the Meet core app.""" import contextlib +import logging from django.conf import settings from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation @@ -10,6 +11,7 @@ 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, @@ -17,6 +19,8 @@ from core.services.marketing import ( get_marketing_service, ) +logger = logging.getLogger(__name__) + class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend): """Custom OpenID Connect (OIDC) Authentication Backend. @@ -59,6 +63,21 @@ 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. diff --git a/src/backend/core/entitlements/__init__.py b/src/backend/core/entitlements/__init__.py new file mode 100644 index 00000000..45c2c588 --- /dev/null +++ b/src/backend/core/entitlements/__init__.py @@ -0,0 +1,29 @@ +"""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 + ) diff --git a/src/backend/core/entitlements/backends/__init__.py b/src/backend/core/entitlements/backends/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/core/entitlements/backends/base.py b/src/backend/core/entitlements/backends/base.py new file mode 100644 index 00000000..256b7235 --- /dev/null +++ b/src/backend/core/entitlements/backends/base.py @@ -0,0 +1,27 @@ +"""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. + """ diff --git a/src/backend/core/entitlements/backends/deploycenter.py b/src/backend/core/entitlements/backends/deploycenter.py new file mode 100644 index 00000000..4231dd9f --- /dev/null +++ b/src/backend/core/entitlements/backends/deploycenter.py @@ -0,0 +1,120 @@ +"""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 diff --git a/src/backend/core/entitlements/backends/local.py b/src/backend/core/entitlements/backends/local.py new file mode 100644 index 00000000..6e99081c --- /dev/null +++ b/src/backend/core/entitlements/backends/local.py @@ -0,0 +1,12 @@ +"""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} diff --git a/src/backend/core/entitlements/factory.py b/src/backend/core/entitlements/factory.py new file mode 100644 index 00000000..c5564f7c --- /dev/null +++ b/src/backend/core/entitlements/factory.py @@ -0,0 +1,13 @@ +"""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) diff --git a/src/backend/core/tests/test_api_users.py b/src/backend/core/tests/test_api_users.py index 37fb32b7..4825da26 100644 --- a/src/backend/core/tests/test_api_users.py +++ b/src/backend/core/tests/test_api_users.py @@ -125,6 +125,7 @@ def test_api_users_retrieve_me_authenticated(settings): "short_name": user.short_name, "language": user.language, "timezone": "UTC", + "can_create": True, } diff --git a/src/backend/core/tests/test_entitlements.py b/src/backend/core/tests/test_entitlements.py new file mode 100644 index 00000000..e1a61db6 --- /dev/null +++ b/src/backend/core/tests/test_entitlements.py @@ -0,0 +1,504 @@ +"""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 diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index 937abff5..8579c611 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -706,6 +706,23 @@ 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 diff --git a/src/frontend/src/features/auth/api/ApiUser.ts b/src/frontend/src/features/auth/api/ApiUser.ts index 5d1e16a1..d6d4562e 100644 --- a/src/frontend/src/features/auth/api/ApiUser.ts +++ b/src/frontend/src/features/auth/api/ApiUser.ts @@ -7,4 +7,5 @@ export type ApiUser = { last_name: string language: BackendLanguage timezone: string + can_create?: boolean } diff --git a/src/frontend/src/features/home/routes/Home.tsx b/src/frontend/src/features/home/routes/Home.tsx index be81e49f..ceb5df28 100644 --- a/src/frontend/src/features/home/routes/Home.tsx +++ b/src/frontend/src/features/home/routes/Home.tsx @@ -148,7 +148,8 @@ const IntroText = styled('div', { export const Home = () => { const { t } = useTranslation('home') - const { isLoggedIn } = useUser() + const { isLoggedIn, user } = useUser() + const canCreate = user?.can_create === true const { userChoices: { username }, @@ -200,45 +201,56 @@ export const Home = () => { })} > {isLoggedIn ? ( - - - - { - const slug = generateRoomId() - createRoom({ slug, username }).then((data) => - navigateTo('room', data.slug, { - state: { create: true, initialRoomData: data }, - }) - ) - }} - data-attr="create-option-instant" - > - - {t('createMenu.instantOption')} - - { - const slug = generateRoomId() - createRoom({ slug, username }).then((data) => - setLaterRoom(data) - ) - }} - data-attr="create-option-later" - > - - {t('createMenu.laterOption')} - - - + canCreate ? ( + + + + { + const slug = generateRoomId() + createRoom({ slug, username }).then((data) => + navigateTo('room', data.slug, { + state: { create: true, initialRoomData: data }, + }) + ) + }} + data-attr="create-option-instant" + > + + {t('createMenu.instantOption')} + + { + const slug = generateRoomId() + createRoom({ slug, username }).then((data) => + setLaterRoom(data) + ) + }} + data-attr="create-option-later" + > + + {t('createMenu.laterOption')} + + + + ) : ( +

+ {t('noAccess')} +

+ ) ) : ( )} diff --git a/src/frontend/src/features/sdk/routes/CreatePopup.tsx b/src/frontend/src/features/sdk/routes/CreatePopup.tsx index 6cd5b3cd..e0b22dbf 100644 --- a/src/frontend/src/features/sdk/routes/CreatePopup.tsx +++ b/src/frontend/src/features/sdk/routes/CreatePopup.tsx @@ -10,7 +10,10 @@ const callbackIdHandler = new CallbackIdHandler() const popupWindow = new PopupWindow() export const CreatePopup = () => { - const { isLoggedIn } = useUser({ fetchUserOptions: { attemptSilent: false } }) + const { isLoggedIn, user } = useUser({ + fetchUserOptions: { attemptSilent: false }, + }) + const canCreate = user?.can_create === true const { mutateAsync: createRoom } = useCreateRoom() const callbackId = useMemo(() => callbackIdHandler.getOrCreate(), []) @@ -55,10 +58,10 @@ export const CreatePopup = () => { console.error('Failed to create meeting room:', error) } } - if (isLoggedIn && callbackId) { + if (isLoggedIn && canCreate && callbackId) { createMeetingRoom() } - }, [isLoggedIn, callbackId, createRoom]) + }, [isLoggedIn, canCreate, callbackId, createRoom]) return (