mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-26 11:58:53 +00:00
✨(entitlements) add Entitlements system with pluggable backends
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.
This commit is contained in:
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -7,4 +7,5 @@ export type ApiUser = {
|
||||
last_name: string
|
||||
language: BackendLanguage
|
||||
timezone: string
|
||||
can_create?: boolean
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
<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>
|
||||
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>
|
||||
)
|
||||
) : (
|
||||
<LoginButton proConnectHint={false} />
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"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,6 +10,7 @@
|
||||
"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,6 +10,7 @@
|
||||
"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,6 +10,7 @@
|
||||
"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}}",
|
||||
|
||||
Reference in New Issue
Block a user