From 57251cf95f93610c8a1c0997d63da5077d61ac31 Mon Sep 17 00:00:00 2001 From: Sylvain Zimmer Date: Fri, 30 Jan 2026 02:03:09 +0100 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8(entitlements)=20add=20Entitlement?= =?UTF-8?q?s=20system=20with=20Deploy=20Center=20backend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/entitlements.md | 163 +++++++++++++++ src/backend/core/api/viewsets/entitlements.py | 68 +++++++ src/backend/core/authentication/backends.py | 57 +++++- src/backend/core/entitlements/__init__.py | 66 ++++++ .../core/entitlements/backends/__init__.py | 0 .../core/entitlements/backends/base.py | 39 ++++ .../entitlements/backends/deploycenter.py | 90 +++++++++ .../core/entitlements/backends/dummy.py | 20 ++ src/backend/core/entitlements/factory.py | 13 ++ src/backend/core/entitlements/middleware.py | 76 +++++++ .../core/tests/entitlements/__init__.py | 0 .../core/tests/entitlements/test_api.py | 144 +++++++++++++ .../core/tests/entitlements/test_backends.py | 189 ++++++++++++++++++ .../core/tests/entitlements/test_cache.py | 187 +++++++++++++++++ .../tests/entitlements/test_middleware.py | 129 ++++++++++++ .../core/tests/entitlements/test_oidc_sync.py | 189 ++++++++++++++++++ src/backend/core/urls.py | 6 + src/backend/messages/settings.py | 14 ++ src/frontend/public/locales/common/en-US.json | 4 +- src/frontend/public/locales/common/fr-FR.json | 4 +- src/frontend/public/locales/common/nl-NL.json | 4 +- .../components/main/header/authenticated.tsx | 2 + .../features/quota/api/use-entitlements.ts | 35 ++++ .../src/features/quota/components/_index.scss | 40 ++++ .../quota/components/quota-widget.tsx | 53 +++++ src/frontend/src/styles/main.scss | 1 + 26 files changed, 1589 insertions(+), 4 deletions(-) create mode 100644 docs/entitlements.md create mode 100644 src/backend/core/api/viewsets/entitlements.py 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/dummy.py create mode 100644 src/backend/core/entitlements/factory.py create mode 100644 src/backend/core/entitlements/middleware.py create mode 100644 src/backend/core/tests/entitlements/__init__.py create mode 100644 src/backend/core/tests/entitlements/test_api.py create mode 100644 src/backend/core/tests/entitlements/test_backends.py create mode 100644 src/backend/core/tests/entitlements/test_cache.py create mode 100644 src/backend/core/tests/entitlements/test_middleware.py create mode 100644 src/backend/core/tests/entitlements/test_oidc_sync.py create mode 100644 src/frontend/src/features/quota/api/use-entitlements.ts create mode 100644 src/frontend/src/features/quota/components/_index.scss create mode 100644 src/frontend/src/features/quota/components/quota-widget.tsx diff --git a/docs/entitlements.md b/docs/entitlements.md new file mode 100644 index 000000000..257d252d0 --- /dev/null +++ b/docs/entitlements.md @@ -0,0 +1,163 @@ +# Entitlements System + +The entitlements system provides a pluggable backend architecture for checking user access rights and mailbox storage quotas. It integrates with the DeployCenter (Espace Operateur) API in production and uses a dummy backend for development. + +## Architecture + +``` +┌─────────────────────────────────────────────┐ +│ Django Cache Layer │ +│ get_user_entitlements() │ +│ get_mailbox_entitlements() │ +└──────────────┬──────────────────────────────┘ + │ +┌──────────────▼──────────────────────────────┐ +│ Backend Factory (singleton) │ +│ get_entitlements_backend() │ +└──────────────┬──────────────────────────────┘ + │ + ┌───────┴───────┐ + │ │ +┌──────▼─────┐ ┌──────▼───────────────┐ +│ Dummy │ │ DeployCenter │ +│ Backend │ │ Backend │ +│ (dev/test) │ │ (production) │ +└────────────┘ └──────────────────────┘ +``` + +### Components + +- **Cached service layer** (`core/entitlements/__init__.py`): Public functions with Django cache. TTL configurable via `ENTITLEMENTS_CACHE_TIMEOUT`. +- **Backend factory** (`core/entitlements/factory.py`): `@functools.cache` singleton that imports and instantiates the configured backend class. +- **Abstract base** (`core/entitlements/backends/base.py`): Defines the `EntitlementsBackend` interface. +- **Dummy backend** (`core/entitlements/backends/dummy.py`): Always grants access, returns no storage info. +- **DeployCenter backend** (`core/entitlements/backends/deploycenter.py`): Calls the DeployCenter API. +- **Middleware** (`core/entitlements/middleware.py`): Enforces `can_access` on all API requests. +- **API endpoint** (`core/api/viewsets/entitlements.py`): `GET /api/v1.0/entitlements/` for the frontend. + +### Error Handling + +All backend methods raise `EntitlementsUnavailableError` on failure. The system is **fail-closed**: if the entitlements service is unavailable, users cannot access the API (503 response). + +## Configuration + +### Environment Variables + +| Variable | Default | Description | +|---|---|---| +| `ENTITLEMENTS_BACKEND` | `core.entitlements.backends.dummy.DummyEntitlementsBackend` | Python import path of the backend class | +| `ENTITLEMENTS_BACKEND_PARAMETERS` | `{}` | JSON object of parameters passed to the backend constructor | +| `ENTITLEMENTS_CACHE_TIMEOUT` | `300` | Cache TTL in seconds | + +### DeployCenter Backend Parameters + +When using `core.entitlements.backends.deploycenter.DeployCenterEntitlementsBackend`, provide these in `ENTITLEMENTS_BACKEND_PARAMETERS`: + +```json +{ + "base_url": "https://deploycenter.example.com", + "service_id": "messages-prod", + "api_key": "your-api-key", + "timeout": 10 +} +``` + +### Example Production Configuration + +```bash +ENTITLEMENTS_BACKEND=core.entitlements.backends.deploycenter.DeployCenterEntitlementsBackend +ENTITLEMENTS_BACKEND_PARAMETERS={"base_url":"https://deploycenter.example.com","service_id":"messages-prod","api_key":"secret-key","timeout":10} +ENTITLEMENTS_CACHE_TIMEOUT=300 +``` + +## Backend Interface + +Custom backends must extend `EntitlementsBackend` and implement: + +```python +class MyBackend(EntitlementsBackend): + def __init__(self, **kwargs): + # Receive ENTITLEMENTS_BACKEND_PARAMETERS as kwargs + pass + + def get_user_entitlements(self, user_sub, user_email, access_token=None): + # Return: {"can_access": bool, "can_admin_maildomains": [str], "operator": dict|None} + # Raise EntitlementsUnavailableError on failure + pass + + def get_mailbox_entitlements(self, mailbox_email, access_token=None): + # Return: {"max_storage": int|None, "storage_used": int|None} + # Raise EntitlementsUnavailableError on failure + pass +``` + +## DeployCenter API + +The DeployCenter backend calls: + +``` +GET {base_url}/api/v1.0/entitlements?service_id=X&account_type=X&account_id=X +``` + +Headers: +- `X-Service-Auth: ApiKey {api_key}` +- `Authorization: Bearer {access_token}` (if provided) + +### User Entitlements Request + +- `account_type=user` +- `account_id=` + +Response fields: `can_access`, `can_admin_maildomains`, `operator` + +### Mailbox Entitlements Request + +- `account_type=mailbox` +- `account_id=` + +Response fields: `max_storage`, `storage_used` + +## Middleware + +`EntitlementsMiddleware` checks `can_access` for all authenticated API requests. + +### Excluded Paths + +The following paths are excluded from entitlements checks: +- `/api/v1.0/config/` +- `/api/v1.0/inbound/mta/` +- `/api/v1.0/mta/` +- `/api/v1.0/metrics/` +- `/api/v1.0/entitlements/` +- `/api/v1.0/prometheus/` + +### Behavior + +- **Unauthenticated requests**: Pass through (DRF handles 401) +- **Superusers**: Pass through +- **`can_access=True`**: Pass through +- **`can_access=False`**: 403 Forbidden +- **Service unavailable**: 503 Service Unavailable + +## OIDC Login Integration + +During OIDC login (`post_get_or_create_user`), the system: + +1. Fetches user entitlements with `force_refresh=True` +2. Syncs `MailDomainAccess` ADMIN records based on `can_admin_maildomains`: + - Creates missing admin accesses for entitled domains + - Removes admin accesses for domains not in the entitled list +3. If `can_admin_maildomains` is `None` (e.g. dummy backend), sync is skipped entirely + +### Deployment Consideration + +Before enabling the DeployCenter backend in production, ensure that existing domain admin assignments are synced in DeployCenter. The entitlements sync will **remove** admin accesses that are not in the DeployCenter response. + +## Frontend Quota Widget + +The frontend includes a quota widget that displays mailbox storage usage in the header. It: + +- Calls `GET /api/v1.0/entitlements/?mailbox_id=` when a mailbox is selected +- Displays a progress bar with `storage_used / max_storage` +- Hides itself when no storage data is available (dummy backend) +- Caches data for 5 minutes on the client side diff --git a/src/backend/core/api/viewsets/entitlements.py b/src/backend/core/api/viewsets/entitlements.py new file mode 100644 index 000000000..d34305d1b --- /dev/null +++ b/src/backend/core/api/viewsets/entitlements.py @@ -0,0 +1,68 @@ +"""API ViewSet for entitlements.""" + +import logging + +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from core.entitlements import ( + EntitlementsUnavailableError, + get_mailbox_entitlements, + get_user_entitlements, +) +from core.models import Mailbox + +logger = logging.getLogger(__name__) + + +class EntitlementsView(APIView): + """API endpoint for retrieving user and mailbox entitlements. + + GET /api/v1.0/entitlements/ + Returns user entitlements from cache. + + GET /api/v1.0/entitlements/?mailbox_id= + Also fetches mailbox entitlements on-demand. + """ + + permission_classes = [IsAuthenticated] + + def get(self, request): + """Return entitlements for the authenticated user.""" + try: + user_entitlements = get_user_entitlements( + request.user.sub, request.user.email + ) + except EntitlementsUnavailableError: + return Response( + {"detail": "Entitlements service unavailable"}, status=503 + ) + + response_data = { + "can_access": user_entitlements.get("can_access", False), + "can_admin_maildomains": user_entitlements.get( + "can_admin_maildomains", [] + ), + "operator": user_entitlements.get("operator"), + "mailbox": None, + } + + mailbox_id = request.query_params.get("mailbox_id") + if mailbox_id: + try: + mailbox = Mailbox.objects.select_related("domain").get(id=mailbox_id) + mailbox_email = str(mailbox) + mailbox_data = get_mailbox_entitlements(mailbox_email) + response_data["mailbox"] = { + "max_storage": mailbox_data.get("max_storage"), + "storage_used": mailbox_data.get("storage_used"), + } + except Mailbox.DoesNotExist: + response_data["mailbox"] = None + except EntitlementsUnavailableError: + return Response( + {"detail": "Entitlements service unavailable"}, status=503 + ) + + return Response(response_data) diff --git a/src/backend/core/authentication/backends.py b/src/backend/core/authentication/backends.py index 451cadb8c..4bd4b9abe 100644 --- a/src/backend/core/authentication/backends.py +++ b/src/backend/core/authentication/backends.py @@ -11,13 +11,14 @@ OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend, ) -from core.enums import MailboxRoleChoices +from core.enums import MailboxRoleChoices, MailDomainAccessRoleChoices from core.models import ( Contact, DuplicateEmailError, Mailbox, MailboxAccess, MailDomain, + MailDomainAccess, User, ) @@ -88,6 +89,60 @@ def post_get_or_create_user(self, user, claims, _user_created): """Post-get or create user.""" if user: self.autojoin_mailbox(user) + self._sync_entitlements(user) + + def _sync_entitlements(self, user): + """Fetch user entitlements and sync MailDomainAccess ADMIN records.""" + from core.entitlements import ( + EntitlementsUnavailableError, + get_user_entitlements, + ) + + try: + entitlements = get_user_entitlements( + user.sub, user.email, force_refresh=True + ) + except EntitlementsUnavailableError: + logger.error( + "Entitlements service unavailable during login for user %s", + user.sub, + ) + return + + admin_domains = entitlements.get("can_admin_maildomains") + if admin_domains is None: + # Backend doesn't support this field (e.g. dummy), skip sync + return + + # Resolve domain names to MailDomain objects that exist in DB + entitled_domains = MailDomain.objects.filter(name__in=admin_domains) + entitled_domain_ids = set(entitled_domains.values_list("id", flat=True)) + + # Create missing MailDomainAccess ADMIN records + existing_accesses = MailDomainAccess.objects.filter( + user=user, role=MailDomainAccessRoleChoices.ADMIN + ) + existing_domain_ids = set( + existing_accesses.values_list("maildomain_id", flat=True) + ) + + # Add new accesses + for domain in entitled_domains: + if domain.id not in existing_domain_ids: + MailDomainAccess.objects.create( + user=user, + maildomain=domain, + role=MailDomainAccessRoleChoices.ADMIN, + ) + + # Remove stale accesses (domains not in the entitled list) + stale_domain_ids = existing_domain_ids - entitled_domain_ids + if stale_domain_ids: + MailDomainAccess.objects.filter( + user=user, + maildomain_id__in=stale_domain_ids, + role=MailDomainAccessRoleChoices.ADMIN, + ).delete() def get_extra_claims(self, user_info): """Get extra claims.""" diff --git a/src/backend/core/entitlements/__init__.py b/src/backend/core/entitlements/__init__.py new file mode 100644 index 000000000..962437dbb --- /dev/null +++ b/src/backend/core/entitlements/__init__.py @@ -0,0 +1,66 @@ +"""Entitlements service layer with Django cache.""" + +import logging + +from django.core.cache import cache +from django.conf import settings + +from core.entitlements.factory import get_entitlements_backend + +logger = logging.getLogger(__name__) + + +class EntitlementsUnavailableError(Exception): + """Raised when the entitlements backend cannot be reached or returns an error.""" + + +def get_user_entitlements( + user_sub, user_email, access_token=None, force_refresh=False +): + """Get user entitlements, using Django cache. + + Returns: + dict: {"can_access": bool, "can_admin_maildomains": [str], "operator": dict|None} + + Raises: + EntitlementsUnavailableError: If the backend cannot be reached and no cache exists. + """ + cache_key = f"entitlements:user:{user_sub}" + + if not force_refresh: + cached = cache.get(cache_key) + if cached is not None: + return cached + + backend = get_entitlements_backend() + result = backend.get_user_entitlements( + user_sub, user_email, access_token=access_token + ) + + cache.set(cache_key, result, settings.ENTITLEMENTS_CACHE_TIMEOUT) + return result + + +def get_mailbox_entitlements(mailbox_email, access_token=None, force_refresh=False): + """Get mailbox entitlements, using Django cache. + + Returns: + dict: {"max_storage": int|None, "storage_used": int|None} + + Raises: + EntitlementsUnavailableError: If the backend cannot be reached and no cache exists. + """ + cache_key = f"entitlements:mailbox:{mailbox_email}" + + if not force_refresh: + cached = cache.get(cache_key) + if cached is not None: + return cached + + backend = get_entitlements_backend() + result = backend.get_mailbox_entitlements( + mailbox_email, access_token=access_token + ) + + cache.set(cache_key, result, settings.ENTITLEMENTS_CACHE_TIMEOUT) + return result diff --git a/src/backend/core/entitlements/backends/__init__.py b/src/backend/core/entitlements/backends/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/backend/core/entitlements/backends/base.py b/src/backend/core/entitlements/backends/base.py new file mode 100644 index 000000000..81cf6e956 --- /dev/null +++ b/src/backend/core/entitlements/backends/base.py @@ -0,0 +1,39 @@ +"""Abstract base class for entitlements backends.""" + +from abc import ABC, abstractmethod + + +class EntitlementsBackend(ABC): + """Abstract base class that defines the interface for entitlements backends.""" + + def __init__(self, **kwargs): + pass + + @abstractmethod + def get_user_entitlements(self, user_sub, user_email, access_token=None): + """Fetch user entitlements. + + Returns: + dict: { + "can_access": bool, + "can_admin_maildomains": list[str], + "operator": dict | None, + } + + Raises: + EntitlementsUnavailableError: If the backend cannot be reached. + """ + + @abstractmethod + def get_mailbox_entitlements(self, mailbox_email, access_token=None): + """Fetch mailbox entitlements. + + Returns: + dict: { + "max_storage": int | None, + "storage_used": int | None, + } + + 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 000000000..6800d85f6 --- /dev/null +++ b/src/backend/core/entitlements/backends/deploycenter.py @@ -0,0 +1,90 @@ +"""DeployCenter (Espace Operateur) entitlements backend.""" + +import logging + +import requests + +from core.entitlements.backends.base import EntitlementsBackend + +logger = logging.getLogger(__name__) + + +class DeployCenterEntitlementsBackend(EntitlementsBackend): + """Backend that fetches entitlements from the DeployCenter API.""" + + def __init__(self, base_url, service_id, api_key, timeout=10, **kwargs): + super().__init__(**kwargs) + self.base_url = base_url.rstrip("/") + self.service_id = service_id + self.api_key = api_key + self.timeout = timeout + + def _make_request(self, account_type, account_id, access_token=None): + """Make a request to the DeployCenter entitlements API. + + Returns: + dict | None: The response data, or None on failure. + """ + url = f"{self.base_url}/api/v1.0/entitlements" + params = { + "service_id": self.service_id, + "account_type": account_type, + "account_id": account_id, + } + headers = { + "X-Service-Auth": f"ApiKey {self.api_key}", + } + if access_token: + headers["Authorization"] = f"Bearer {access_token}" + + try: + response = requests.get( + url, params=params, headers=headers, timeout=self.timeout + ) + response.raise_for_status() + return response.json() + except requests.RequestException: + logger.warning( + "DeployCenter entitlements request failed for %s/%s", + account_type, + account_id, + exc_info=True, + ) + return None + + def get_user_entitlements(self, user_sub, user_email, access_token=None): + """Fetch user entitlements from DeployCenter. + + Raises: + EntitlementsUnavailableError: If the request fails (fail closed). + """ + from core.entitlements import EntitlementsUnavailableError + + data = self._make_request("user", user_email, access_token=access_token) + if data is None: + raise EntitlementsUnavailableError( + "Failed to fetch user entitlements from DeployCenter" + ) + return { + "can_access": data.get("can_access", False), + "can_admin_maildomains": data.get("can_admin_maildomains", []), + "operator": data.get("operator"), + } + + def get_mailbox_entitlements(self, mailbox_email, access_token=None): + """Fetch mailbox entitlements from DeployCenter. + + Raises: + EntitlementsUnavailableError: If the request fails (fail closed). + """ + from core.entitlements import EntitlementsUnavailableError + + data = self._make_request("mailbox", mailbox_email, access_token=access_token) + if data is None: + raise EntitlementsUnavailableError( + "Failed to fetch mailbox entitlements from DeployCenter" + ) + return { + "max_storage": data.get("max_storage"), + "storage_used": data.get("storage_used"), + } diff --git a/src/backend/core/entitlements/backends/dummy.py b/src/backend/core/entitlements/backends/dummy.py new file mode 100644 index 000000000..ecb8db752 --- /dev/null +++ b/src/backend/core/entitlements/backends/dummy.py @@ -0,0 +1,20 @@ +"""Dummy entitlements backend for development and testing.""" + +from core.entitlements.backends.base import EntitlementsBackend + + +class DummyEntitlementsBackend(EntitlementsBackend): + """Dummy backend that always grants access with no storage limits.""" + + def get_user_entitlements(self, user_sub, user_email, access_token=None): + return { + "can_access": True, + "can_admin_maildomains": [], + "operator": None, + } + + def get_mailbox_entitlements(self, mailbox_email, access_token=None): + return { + "max_storage": None, + "storage_used": None, + } diff --git a/src/backend/core/entitlements/factory.py b/src/backend/core/entitlements/factory.py new file mode 100644 index 000000000..c5564f7c7 --- /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/entitlements/middleware.py b/src/backend/core/entitlements/middleware.py new file mode 100644 index 000000000..7070c073c --- /dev/null +++ b/src/backend/core/entitlements/middleware.py @@ -0,0 +1,76 @@ +"""Middleware to enforce entitlements-based access control on API requests.""" + +import logging +import re + +from django.conf import settings +from django.http import JsonResponse + +from core.entitlements import ( + EntitlementsUnavailableError, + get_user_entitlements, +) + +logger = logging.getLogger(__name__) + +# Paths that should be excluded from entitlements checks +EXCLUDED_PATH_PATTERNS = [ + re.compile(rf"^/api/{settings.API_VERSION}/config/"), + re.compile(rf"^/api/{settings.API_VERSION}/inbound/mta/"), + re.compile(rf"^/api/{settings.API_VERSION}/mta/"), + re.compile(rf"^/api/{settings.API_VERSION}/metrics/"), + re.compile(rf"^/api/{settings.API_VERSION}/entitlements/"), + re.compile(rf"^/api/{settings.API_VERSION}/prometheus/"), +] + + +class EntitlementsMiddleware: + """Middleware that checks can_access entitlement for authenticated API requests. + + - Skips non-API paths + - Skips excluded paths (config, MTA, metrics, entitlements, prometheus) + - Skips unauthenticated requests and superusers + - Returns 403 if can_access is False + - Returns 503 if entitlements service is unavailable (fail closed) + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + # Only check API paths + if not request.path.startswith(f"/api/{settings.API_VERSION}/"): + return self.get_response(request) + + # Skip excluded paths + for pattern in EXCLUDED_PATH_PATTERNS: + if pattern.match(request.path): + return self.get_response(request) + + # Skip unauthenticated requests (they'll get 401 from DRF) + if not hasattr(request, "user") or not request.user.is_authenticated: + return self.get_response(request) + + # Skip superusers + if request.user.is_superuser: + return self.get_response(request) + + try: + entitlements = get_user_entitlements( + request.user.sub, request.user.email + ) + except EntitlementsUnavailableError: + logger.warning( + "Entitlements service unavailable for user %s", + request.user.sub, + ) + return JsonResponse( + {"detail": "Entitlements service unavailable"}, status=503 + ) + + if not entitlements.get("can_access", False): + return JsonResponse( + {"detail": "Access denied by entitlements policy"}, status=403 + ) + + return self.get_response(request) diff --git a/src/backend/core/tests/entitlements/__init__.py b/src/backend/core/tests/entitlements/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/backend/core/tests/entitlements/test_api.py b/src/backend/core/tests/entitlements/test_api.py new file mode 100644 index 000000000..8ac7f1bc4 --- /dev/null +++ b/src/backend/core/tests/entitlements/test_api.py @@ -0,0 +1,144 @@ +"""Tests for the entitlements API endpoint.""" + +from unittest import mock + +import pytest +from django.core.cache import cache +from rest_framework.test import APIClient + +from core import factories +from core.entitlements import EntitlementsUnavailableError + +pytestmark = pytest.mark.django_db + + +@pytest.fixture(autouse=True) +def _clear_cache(): + cache.clear() + yield + cache.clear() + + +@pytest.fixture +def api_client(): + return APIClient() + + +class TestEntitlementsEndpoint: + """Tests for GET /api/v1.0/entitlements/.""" + + def test_anonymous_user_gets_401(self, api_client): + response = api_client.get("/api/v1.0/entitlements/") + assert response.status_code == 401 + + @mock.patch("core.api.viewsets.entitlements.get_user_entitlements") + def test_returns_user_entitlements(self, mock_get_user, api_client): + user = factories.UserFactory() + api_client.force_authenticate(user=user) + + mock_get_user.return_value = { + "can_access": True, + "can_admin_maildomains": ["example.com"], + "operator": {"name": "Test Op"}, + } + + response = api_client.get("/api/v1.0/entitlements/") + + assert response.status_code == 200 + data = response.json() + assert data["can_access"] is True + assert data["can_admin_maildomains"] == ["example.com"] + assert data["operator"] == {"name": "Test Op"} + assert data["mailbox"] is None + + @mock.patch("core.api.viewsets.entitlements.get_mailbox_entitlements") + @mock.patch("core.api.viewsets.entitlements.get_user_entitlements") + def test_returns_mailbox_entitlements( + self, mock_get_user, mock_get_mailbox, api_client + ): + user = factories.UserFactory() + domain = factories.MailDomainFactory(name="example.com") + mailbox = factories.MailboxFactory( + local_part="john", domain=domain, users_admin=[user] + ) + api_client.force_authenticate(user=user) + + mock_get_user.return_value = { + "can_access": True, + "can_admin_maildomains": [], + "operator": None, + } + mock_get_mailbox.return_value = { + "max_storage": 5368709120, + "storage_used": 1073741824, + } + + response = api_client.get( + "/api/v1.0/entitlements/", {"mailbox_id": str(mailbox.id)} + ) + + assert response.status_code == 200 + data = response.json() + assert data["mailbox"] == { + "max_storage": 5368709120, + "storage_used": 1073741824, + } + mock_get_mailbox.assert_called_once_with(f"john@example.com") + + @mock.patch("core.api.viewsets.entitlements.get_user_entitlements") + def test_invalid_mailbox_id_returns_null_mailbox( + self, mock_get_user, api_client + ): + user = factories.UserFactory() + api_client.force_authenticate(user=user) + + mock_get_user.return_value = { + "can_access": True, + "can_admin_maildomains": [], + "operator": None, + } + + response = api_client.get( + "/api/v1.0/entitlements/", + {"mailbox_id": "00000000-0000-0000-0000-000000000000"}, + ) + + assert response.status_code == 200 + assert response.json()["mailbox"] is None + + @mock.patch("core.api.viewsets.entitlements.get_user_entitlements") + def test_returns_503_when_user_entitlements_unavailable( + self, mock_get_user, api_client + ): + user = factories.UserFactory() + api_client.force_authenticate(user=user) + + mock_get_user.side_effect = EntitlementsUnavailableError("Down") + + response = api_client.get("/api/v1.0/entitlements/") + assert response.status_code == 503 + assert response.json()["detail"] == "Entitlements service unavailable" + + @mock.patch("core.api.viewsets.entitlements.get_mailbox_entitlements") + @mock.patch("core.api.viewsets.entitlements.get_user_entitlements") + def test_returns_503_when_mailbox_entitlements_unavailable( + self, mock_get_user, mock_get_mailbox, api_client + ): + user = factories.UserFactory() + domain = factories.MailDomainFactory(name="example.com") + mailbox = factories.MailboxFactory( + local_part="john", domain=domain, users_admin=[user] + ) + api_client.force_authenticate(user=user) + + mock_get_user.return_value = { + "can_access": True, + "can_admin_maildomains": [], + "operator": None, + } + mock_get_mailbox.side_effect = EntitlementsUnavailableError("Down") + + response = api_client.get( + "/api/v1.0/entitlements/", {"mailbox_id": str(mailbox.id)} + ) + assert response.status_code == 503 diff --git a/src/backend/core/tests/entitlements/test_backends.py b/src/backend/core/tests/entitlements/test_backends.py new file mode 100644 index 000000000..d09b05804 --- /dev/null +++ b/src/backend/core/tests/entitlements/test_backends.py @@ -0,0 +1,189 @@ +"""Unit tests for entitlements backends.""" + +import pytest +import responses + +from core.entitlements import EntitlementsUnavailableError +from core.entitlements.backends.deploycenter import DeployCenterEntitlementsBackend +from core.entitlements.backends.dummy import DummyEntitlementsBackend + +pytestmark = pytest.mark.django_db + + +class TestDummyBackend: + """Tests for the DummyEntitlementsBackend.""" + + def test_get_user_entitlements(self): + backend = DummyEntitlementsBackend() + result = backend.get_user_entitlements("user-sub", "user@example.com") + assert result == { + "can_access": True, + "can_admin_maildomains": [], + "operator": None, + } + + def test_get_mailbox_entitlements(self): + backend = DummyEntitlementsBackend() + result = backend.get_mailbox_entitlements("user@example.com") + assert result == { + "max_storage": None, + "storage_used": None, + } + + +class TestDeployCenterBackend: + """Tests for the DeployCenterEntitlementsBackend.""" + + def _get_backend(self): + return DeployCenterEntitlementsBackend( + base_url="https://deploycenter.example.com", + service_id="test-service", + api_key="test-api-key", + timeout=5, + ) + + @responses.activate + def test_get_user_entitlements_success(self): + responses.add( + responses.GET, + "https://deploycenter.example.com/api/v1.0/entitlements", + json={ + "can_access": True, + "can_admin_maildomains": ["example.com", "test.org"], + "operator": {"name": "Test Operator"}, + }, + status=200, + ) + + backend = self._get_backend() + result = backend.get_user_entitlements( + "user-sub", "user@example.com", access_token="test-token" + ) + + assert result == { + "can_access": True, + "can_admin_maildomains": ["example.com", "test.org"], + "operator": {"name": "Test Operator"}, + } + + # Verify request parameters + request = responses.calls[0].request + assert "service_id=test-service" in request.url + assert "account_type=user" in request.url + assert "account_id=user%40example.com" in request.url + assert request.headers["X-Service-Auth"] == "ApiKey test-api-key" + assert request.headers["Authorization"] == "Bearer test-token" + + @responses.activate + def test_get_user_entitlements_no_access_token(self): + responses.add( + responses.GET, + "https://deploycenter.example.com/api/v1.0/entitlements", + json={"can_access": True, "can_admin_maildomains": []}, + status=200, + ) + + backend = self._get_backend() + result = backend.get_user_entitlements("user-sub", "user@example.com") + + request = responses.calls[0].request + assert "Authorization" not in request.headers + + @responses.activate + def test_get_user_entitlements_can_access_false(self): + responses.add( + responses.GET, + "https://deploycenter.example.com/api/v1.0/entitlements", + json={"can_access": False, "can_admin_maildomains": []}, + status=200, + ) + + backend = self._get_backend() + result = backend.get_user_entitlements("user-sub", "user@example.com") + assert result["can_access"] is False + + @responses.activate + def test_get_user_entitlements_server_error_raises(self): + responses.add( + responses.GET, + "https://deploycenter.example.com/api/v1.0/entitlements", + status=500, + ) + + backend = self._get_backend() + with pytest.raises(EntitlementsUnavailableError): + backend.get_user_entitlements("user-sub", "user@example.com") + + @responses.activate + def test_get_user_entitlements_timeout_raises(self): + responses.add( + responses.GET, + "https://deploycenter.example.com/api/v1.0/entitlements", + body=ConnectionError("Connection timed out"), + ) + + backend = self._get_backend() + with pytest.raises(EntitlementsUnavailableError): + backend.get_user_entitlements("user-sub", "user@example.com") + + @responses.activate + def test_get_mailbox_entitlements_success(self): + responses.add( + responses.GET, + "https://deploycenter.example.com/api/v1.0/entitlements", + json={ + "max_storage": 5368709120, + "storage_used": 1073741824, + }, + status=200, + ) + + backend = self._get_backend() + result = backend.get_mailbox_entitlements("mailbox@example.com") + + assert result == { + "max_storage": 5368709120, + "storage_used": 1073741824, + } + + request = responses.calls[0].request + assert "account_type=mailbox" in request.url + assert "account_id=mailbox%40example.com" in request.url + + @responses.activate + def test_get_mailbox_entitlements_server_error_raises(self): + responses.add( + responses.GET, + "https://deploycenter.example.com/api/v1.0/entitlements", + status=503, + ) + + backend = self._get_backend() + with pytest.raises(EntitlementsUnavailableError): + backend.get_mailbox_entitlements("mailbox@example.com") + + @responses.activate + def test_get_user_entitlements_missing_fields_defaults(self): + """Backend should provide sensible defaults for missing response fields.""" + responses.add( + responses.GET, + "https://deploycenter.example.com/api/v1.0/entitlements", + json={}, + status=200, + ) + + backend = self._get_backend() + result = backend.get_user_entitlements("user-sub", "user@example.com") + assert result == { + "can_access": False, + "can_admin_maildomains": [], + "operator": None, + } + + def test_base_url_trailing_slash_stripped(self): + backend = DeployCenterEntitlementsBackend( + base_url="https://deploycenter.example.com/", + service_id="svc", + api_key="key", + ) + assert backend.base_url == "https://deploycenter.example.com" diff --git a/src/backend/core/tests/entitlements/test_cache.py b/src/backend/core/tests/entitlements/test_cache.py new file mode 100644 index 000000000..1887d05a0 --- /dev/null +++ b/src/backend/core/tests/entitlements/test_cache.py @@ -0,0 +1,187 @@ +"""Tests for the entitlements caching layer.""" + +from unittest import mock + +import pytest +from django.core.cache import cache +from django.test import override_settings + +from core.entitlements import ( + EntitlementsUnavailableError, + get_mailbox_entitlements, + get_user_entitlements, +) + +pytestmark = pytest.mark.django_db + + +@pytest.fixture(autouse=True) +def _clear_cache(): + """Clear cache before each test.""" + cache.clear() + yield + cache.clear() + + +class TestGetUserEntitlements: + """Tests for the get_user_entitlements cached function.""" + + @mock.patch("core.entitlements.get_entitlements_backend") + def test_calls_backend_on_cache_miss(self, mock_get_backend): + mock_backend = mock.Mock() + mock_backend.get_user_entitlements.return_value = { + "can_access": True, + "can_admin_maildomains": [], + "operator": None, + } + mock_get_backend.return_value = mock_backend + + result = get_user_entitlements("user-sub", "user@example.com") + + assert result["can_access"] is True + mock_backend.get_user_entitlements.assert_called_once_with( + "user-sub", "user@example.com", access_token=None + ) + + @mock.patch("core.entitlements.get_entitlements_backend") + def test_returns_cached_value_on_hit(self, mock_get_backend): + mock_backend = mock.Mock() + mock_backend.get_user_entitlements.return_value = { + "can_access": True, + "can_admin_maildomains": ["example.com"], + "operator": None, + } + mock_get_backend.return_value = mock_backend + + # First call populates cache + result1 = get_user_entitlements("user-sub", "user@example.com") + # Second call should use cache + result2 = get_user_entitlements("user-sub", "user@example.com") + + assert result1 == result2 + assert mock_backend.get_user_entitlements.call_count == 1 + + @mock.patch("core.entitlements.get_entitlements_backend") + def test_force_refresh_bypasses_cache(self, mock_get_backend): + mock_backend = mock.Mock() + mock_backend.get_user_entitlements.return_value = { + "can_access": True, + "can_admin_maildomains": [], + "operator": None, + } + mock_get_backend.return_value = mock_backend + + get_user_entitlements("user-sub", "user@example.com") + get_user_entitlements( + "user-sub", "user@example.com", force_refresh=True + ) + + assert mock_backend.get_user_entitlements.call_count == 2 + + @mock.patch("core.entitlements.get_entitlements_backend") + def test_passes_access_token(self, mock_get_backend): + mock_backend = mock.Mock() + mock_backend.get_user_entitlements.return_value = { + "can_access": True, + "can_admin_maildomains": [], + "operator": None, + } + mock_get_backend.return_value = mock_backend + + get_user_entitlements( + "user-sub", "user@example.com", access_token="my-token" + ) + + mock_backend.get_user_entitlements.assert_called_once_with( + "user-sub", "user@example.com", access_token="my-token" + ) + + @mock.patch("core.entitlements.get_entitlements_backend") + def test_propagates_backend_error(self, mock_get_backend): + mock_backend = mock.Mock() + mock_backend.get_user_entitlements.side_effect = ( + EntitlementsUnavailableError("Backend down") + ) + mock_get_backend.return_value = mock_backend + + with pytest.raises(EntitlementsUnavailableError): + get_user_entitlements("user-sub", "user@example.com") + + @mock.patch("core.entitlements.get_entitlements_backend") + def test_different_users_have_different_cache_keys(self, mock_get_backend): + mock_backend = mock.Mock() + mock_backend.get_user_entitlements.side_effect = [ + {"can_access": True, "can_admin_maildomains": [], "operator": None}, + { + "can_access": False, + "can_admin_maildomains": [], + "operator": None, + }, + ] + mock_get_backend.return_value = mock_backend + + result1 = get_user_entitlements("user1", "user1@example.com") + result2 = get_user_entitlements("user2", "user2@example.com") + + assert result1["can_access"] is True + assert result2["can_access"] is False + assert mock_backend.get_user_entitlements.call_count == 2 + + +class TestGetMailboxEntitlements: + """Tests for the get_mailbox_entitlements cached function.""" + + @mock.patch("core.entitlements.get_entitlements_backend") + def test_calls_backend_on_cache_miss(self, mock_get_backend): + mock_backend = mock.Mock() + mock_backend.get_mailbox_entitlements.return_value = { + "max_storage": 5000000000, + "storage_used": 1000000000, + } + mock_get_backend.return_value = mock_backend + + result = get_mailbox_entitlements("mailbox@example.com") + + assert result["max_storage"] == 5000000000 + mock_backend.get_mailbox_entitlements.assert_called_once_with( + "mailbox@example.com", access_token=None + ) + + @mock.patch("core.entitlements.get_entitlements_backend") + def test_returns_cached_value_on_hit(self, mock_get_backend): + mock_backend = mock.Mock() + mock_backend.get_mailbox_entitlements.return_value = { + "max_storage": 5000000000, + "storage_used": 1000000000, + } + mock_get_backend.return_value = mock_backend + + get_mailbox_entitlements("mailbox@example.com") + get_mailbox_entitlements("mailbox@example.com") + + assert mock_backend.get_mailbox_entitlements.call_count == 1 + + @mock.patch("core.entitlements.get_entitlements_backend") + def test_force_refresh_bypasses_cache(self, mock_get_backend): + mock_backend = mock.Mock() + mock_backend.get_mailbox_entitlements.return_value = { + "max_storage": None, + "storage_used": None, + } + mock_get_backend.return_value = mock_backend + + get_mailbox_entitlements("mailbox@example.com") + get_mailbox_entitlements("mailbox@example.com", force_refresh=True) + + assert mock_backend.get_mailbox_entitlements.call_count == 2 + + @mock.patch("core.entitlements.get_entitlements_backend") + def test_propagates_backend_error(self, mock_get_backend): + mock_backend = mock.Mock() + mock_backend.get_mailbox_entitlements.side_effect = ( + EntitlementsUnavailableError("Backend down") + ) + mock_get_backend.return_value = mock_backend + + with pytest.raises(EntitlementsUnavailableError): + get_mailbox_entitlements("mailbox@example.com") diff --git a/src/backend/core/tests/entitlements/test_middleware.py b/src/backend/core/tests/entitlements/test_middleware.py new file mode 100644 index 000000000..0c8272099 --- /dev/null +++ b/src/backend/core/tests/entitlements/test_middleware.py @@ -0,0 +1,129 @@ +"""Tests for the entitlements middleware.""" + +from unittest import mock + +import pytest +from django.core.cache import cache +from rest_framework.test import APIClient + +from core import factories +from core.entitlements import EntitlementsUnavailableError + +pytestmark = pytest.mark.django_db + + +@pytest.fixture(autouse=True) +def _clear_cache(): + cache.clear() + yield + cache.clear() + + +@pytest.fixture +def api_client(): + return APIClient() + + +class TestEntitlementsMiddleware: + """Tests for the EntitlementsMiddleware.""" + + def test_allows_unauthenticated_requests(self, api_client): + """Unauthenticated requests should pass through (DRF handles 401).""" + response = api_client.get("/api/v1.0/config/") + # Config endpoint allows any - should pass through + assert response.status_code == 200 + + def test_skips_non_api_paths(self, api_client): + """Non-API paths should not be checked.""" + response = api_client.get("/admin/") + # May get redirect or 404, but not 403/503 from middleware + assert response.status_code != 403 + assert response.status_code != 503 + + def test_skips_config_endpoint(self, api_client): + """Config endpoint should be excluded from entitlements checks.""" + response = api_client.get("/api/v1.0/config/") + assert response.status_code == 200 + + @mock.patch("core.entitlements.middleware.get_user_entitlements") + def test_skips_entitlements_endpoint(self, mock_get, api_client): + """Entitlements endpoint itself should be excluded.""" + user = factories.UserFactory() + api_client.force_authenticate(user=user) + + mock_get.return_value = { + "can_access": True, + "can_admin_maildomains": [], + "operator": None, + } + + response = api_client.get("/api/v1.0/entitlements/") + # The middleware should not call get_user_entitlements for this path + mock_get.assert_not_called() + + @mock.patch("core.entitlements.middleware.get_user_entitlements") + def test_skips_superusers(self, mock_get, api_client): + """Superusers should bypass entitlements checks.""" + user = factories.UserFactory(is_superuser=True) + api_client.force_authenticate(user=user) + + response = api_client.get("/api/v1.0/users/") + mock_get.assert_not_called() + + @mock.patch("core.entitlements.middleware.get_user_entitlements") + def test_allows_access_when_can_access_true(self, mock_get, api_client): + """User with can_access=True should be allowed.""" + user = factories.UserFactory() + api_client.force_authenticate(user=user) + + mock_get.return_value = { + "can_access": True, + "can_admin_maildomains": [], + "operator": None, + } + + response = api_client.get("/api/v1.0/users/") + # Should pass through middleware (may get whatever the view returns) + assert response.status_code != 403 + assert response.status_code != 503 + + @mock.patch("core.entitlements.middleware.get_user_entitlements") + def test_blocks_access_when_can_access_false(self, mock_get, api_client): + """User with can_access=False should get 403.""" + user = factories.UserFactory() + api_client.force_authenticate(user=user) + + mock_get.return_value = { + "can_access": False, + "can_admin_maildomains": [], + "operator": None, + } + + response = api_client.get("/api/v1.0/users/") + assert response.status_code == 403 + assert response.json()["detail"] == "Access denied by entitlements policy" + + @mock.patch("core.entitlements.middleware.get_user_entitlements") + def test_returns_503_on_unavailable_error(self, mock_get, api_client): + """Should return 503 when entitlements service is unavailable.""" + user = factories.UserFactory() + api_client.force_authenticate(user=user) + + mock_get.side_effect = EntitlementsUnavailableError("Backend down") + + response = api_client.get("/api/v1.0/users/") + assert response.status_code == 503 + assert response.json()["detail"] == "Entitlements service unavailable" + + @mock.patch("core.entitlements.middleware.get_user_entitlements") + def test_skips_mta_endpoint(self, mock_get, api_client): + """MTA endpoints should be excluded from entitlements checks.""" + # This endpoint requires special auth, so we won't get 403 from middleware + response = api_client.post("/api/v1.0/mta/check-recipients/") + mock_get.assert_not_called() + + @mock.patch("core.entitlements.middleware.get_user_entitlements") + def test_skips_metrics_endpoint(self, mock_get, api_client): + """Metrics endpoints should be excluded from entitlements checks.""" + response = api_client.get("/api/v1.0/metrics/maildomain_users/") + mock_get.assert_not_called() diff --git a/src/backend/core/tests/entitlements/test_oidc_sync.py b/src/backend/core/tests/entitlements/test_oidc_sync.py new file mode 100644 index 000000000..093bb57ff --- /dev/null +++ b/src/backend/core/tests/entitlements/test_oidc_sync.py @@ -0,0 +1,189 @@ +"""Tests for entitlements sync during OIDC login.""" + +from unittest import mock + +import pytest +from django.core.cache import cache + +from core import factories +from core.authentication.backends import OIDCAuthenticationBackend +from core.entitlements import EntitlementsUnavailableError +from core.enums import MailDomainAccessRoleChoices +from core.models import MailDomainAccess + +pytestmark = pytest.mark.django_db + + +@pytest.fixture(autouse=True) +def _clear_cache(): + cache.clear() + yield + cache.clear() + + +class TestOIDCSyncEntitlements: + """Tests for _sync_entitlements called during OIDC login.""" + + @mock.patch("core.authentication.backends.get_user_entitlements") + def test_creates_admin_access_for_entitled_domains(self, mock_get): + user = factories.UserFactory() + domain1 = factories.MailDomainFactory(name="domain1.com") + domain2 = factories.MailDomainFactory(name="domain2.com") + + mock_get.return_value = { + "can_access": True, + "can_admin_maildomains": ["domain1.com", "domain2.com"], + "operator": None, + } + + backend = OIDCAuthenticationBackend() + backend._sync_entitlements(user) + + assert MailDomainAccess.objects.filter( + user=user, maildomain=domain1, role=MailDomainAccessRoleChoices.ADMIN + ).exists() + assert MailDomainAccess.objects.filter( + user=user, maildomain=domain2, role=MailDomainAccessRoleChoices.ADMIN + ).exists() + + @mock.patch("core.authentication.backends.get_user_entitlements") + def test_removes_stale_admin_access(self, mock_get): + user = factories.UserFactory() + domain1 = factories.MailDomainFactory(name="domain1.com") + domain2 = factories.MailDomainFactory(name="domain2.com") + + # User currently has admin access to both domains + factories.MailDomainAccessFactory( + user=user, maildomain=domain1, role=MailDomainAccessRoleChoices.ADMIN + ) + factories.MailDomainAccessFactory( + user=user, maildomain=domain2, role=MailDomainAccessRoleChoices.ADMIN + ) + + # Entitlements now only include domain1 + mock_get.return_value = { + "can_access": True, + "can_admin_maildomains": ["domain1.com"], + "operator": None, + } + + backend = OIDCAuthenticationBackend() + backend._sync_entitlements(user) + + assert MailDomainAccess.objects.filter( + user=user, maildomain=domain1 + ).exists() + assert not MailDomainAccess.objects.filter( + user=user, maildomain=domain2 + ).exists() + + @mock.patch("core.authentication.backends.get_user_entitlements") + def test_skips_nonexistent_domains(self, mock_get): + user = factories.UserFactory() + + mock_get.return_value = { + "can_access": True, + "can_admin_maildomains": ["nonexistent.com"], + "operator": None, + } + + backend = OIDCAuthenticationBackend() + backend._sync_entitlements(user) + + assert MailDomainAccess.objects.filter(user=user).count() == 0 + + @mock.patch("core.authentication.backends.get_user_entitlements") + def test_handles_unavailable_error(self, mock_get): + """On EntitlementsUnavailableError, sync should be skipped without crash.""" + user = factories.UserFactory() + domain = factories.MailDomainFactory(name="domain.com") + factories.MailDomainAccessFactory( + user=user, maildomain=domain, role=MailDomainAccessRoleChoices.ADMIN + ) + + mock_get.side_effect = EntitlementsUnavailableError("Backend down") + + backend = OIDCAuthenticationBackend() + backend._sync_entitlements(user) + + # Existing access should NOT be removed + assert MailDomainAccess.objects.filter(user=user, maildomain=domain).exists() + + @mock.patch("core.authentication.backends.get_user_entitlements") + def test_skips_sync_when_field_not_in_response(self, mock_get): + """If can_admin_maildomains is None (e.g. dummy backend), skip sync entirely.""" + user = factories.UserFactory() + domain = factories.MailDomainFactory(name="domain.com") + factories.MailDomainAccessFactory( + user=user, maildomain=domain, role=MailDomainAccessRoleChoices.ADMIN + ) + + mock_get.return_value = { + "can_access": True, + "can_admin_maildomains": None, + "operator": None, + } + + backend = OIDCAuthenticationBackend() + backend._sync_entitlements(user) + + # Existing access should still be there + assert MailDomainAccess.objects.filter(user=user, maildomain=domain).exists() + + @mock.patch("core.authentication.backends.get_user_entitlements") + def test_empty_list_removes_all_admin_accesses(self, mock_get): + """An empty list means the user has no admin access to any domain.""" + user = factories.UserFactory() + domain = factories.MailDomainFactory(name="domain.com") + factories.MailDomainAccessFactory( + user=user, maildomain=domain, role=MailDomainAccessRoleChoices.ADMIN + ) + + mock_get.return_value = { + "can_access": True, + "can_admin_maildomains": [], + "operator": None, + } + + backend = OIDCAuthenticationBackend() + backend._sync_entitlements(user) + + assert MailDomainAccess.objects.filter(user=user).count() == 0 + + @mock.patch("core.authentication.backends.get_user_entitlements") + def test_does_not_duplicate_existing_access(self, mock_get): + """Should not create duplicate MailDomainAccess records.""" + user = factories.UserFactory() + domain = factories.MailDomainFactory(name="domain.com") + factories.MailDomainAccessFactory( + user=user, maildomain=domain, role=MailDomainAccessRoleChoices.ADMIN + ) + + mock_get.return_value = { + "can_access": True, + "can_admin_maildomains": ["domain.com"], + "operator": None, + } + + backend = OIDCAuthenticationBackend() + backend._sync_entitlements(user) + + assert MailDomainAccess.objects.filter(user=user, maildomain=domain).count() == 1 + + @mock.patch("core.authentication.backends.get_user_entitlements") + def test_force_refresh_is_used(self, mock_get): + """Should call get_user_entitlements with force_refresh=True.""" + user = factories.UserFactory() + + mock_get.return_value = { + "can_access": True, + "can_admin_maildomains": [], + "operator": None, + } + + backend = OIDCAuthenticationBackend() + backend._sync_entitlements(user) + + mock_get.assert_called_once_with( + user.sub, user.email, force_refresh=True + ) diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index 8845f16ac..bb24a5915 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -10,6 +10,7 @@ from core.api.viewsets.config import ConfigView from core.api.viewsets.contacts import ContactViewSet from core.api.viewsets.draft import DraftMessageView +from core.api.viewsets.entitlements import EntitlementsView from core.api.viewsets.drive import DriveAPIView from core.api.viewsets.flag import ChangeFlagView from core.api.viewsets.image_proxy import ImageProxyViewSet @@ -183,6 +184,11 @@ ), ), path(f"api/{settings.API_VERSION}/config/", ConfigView.as_view()), + path( + f"api/{settings.API_VERSION}/entitlements/", + EntitlementsView.as_view(), + name="entitlements", + ), path( f"api/{settings.API_VERSION}/flag/", ChangeFlagView.as_view(), diff --git a/src/backend/messages/settings.py b/src/backend/messages/settings.py index 1c31a5d6e..8f4c11c68 100755 --- a/src/backend/messages/settings.py +++ b/src/backend/messages/settings.py @@ -484,6 +484,7 @@ class Base(Configuration): "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", + "core.entitlements.middleware.EntitlementsMiddleware", "django.contrib.messages.middleware.MessageMiddleware", ] @@ -758,6 +759,19 @@ class Base(Configuration): AI_BASE_URL = values.Value(None, environ_name="AI_BASE_URL", environ_prefix=None) AI_MODEL = values.Value(None, environ_name="AI_MODEL", environ_prefix=None) + # Entitlements + ENTITLEMENTS_BACKEND = values.Value( + "core.entitlements.backends.dummy.DummyEntitlementsBackend", + environ_name="ENTITLEMENTS_BACKEND", + environ_prefix=None, + ) + ENTITLEMENTS_BACKEND_PARAMETERS = JSONValue( + {}, environ_name="ENTITLEMENTS_BACKEND_PARAMETERS", environ_prefix=None + ) + ENTITLEMENTS_CACHE_TIMEOUT = values.PositiveIntegerValue( + 300, environ_name="ENTITLEMENTS_CACHE_TIMEOUT", environ_prefix=None + ) + # Feature flags FEATURE_AI_SUMMARY = values.BooleanValue( default=False, environ_name="FEATURE_AI_SUMMARY", environ_prefix=None diff --git a/src/frontend/public/locales/common/en-US.json b/src/frontend/public/locales/common/en-US.json index ac8dfa8ea..7ba129af8 100644 --- a/src/frontend/public/locales/common/en-US.json +++ b/src/frontend/public/locales/common/en-US.json @@ -519,5 +519,7 @@ "You must confirm this statement.": "You must confirm this statement.", "Your email...": "Your email...", "Your messages have been imported successfully!": "Your messages have been imported successfully!", - "Your session has expired. Please log in again.": "Your session has expired. Please log in again." + "Your session has expired. Please log in again.": "Your session has expired. Please log in again.", + "Storage": "Storage", + "{{used}} / {{total}} used": "{{used}} / {{total}} used" } diff --git a/src/frontend/public/locales/common/fr-FR.json b/src/frontend/public/locales/common/fr-FR.json index 61c12fa5a..0f2ae6e44 100644 --- a/src/frontend/public/locales/common/fr-FR.json +++ b/src/frontend/public/locales/common/fr-FR.json @@ -521,5 +521,7 @@ "You must confirm this statement.": "Vous devez confirmer cette déclaration.", "Your email...": "Renseigner votre email...", "Your messages have been imported successfully!": "Vos messages ont été importés avec succès !", - "Your session has expired. Please log in again.": "Votre session a expiré. Veuillez vous reconnecter." + "Your session has expired. Please log in again.": "Votre session a expiré. Veuillez vous reconnecter.", + "Storage": "Stockage", + "{{used}} / {{total}} used": "{{used}} / {{total}} utilisé" } diff --git a/src/frontend/public/locales/common/nl-NL.json b/src/frontend/public/locales/common/nl-NL.json index 72f0b6679..2110725c3 100644 --- a/src/frontend/public/locales/common/nl-NL.json +++ b/src/frontend/public/locales/common/nl-NL.json @@ -384,5 +384,7 @@ "You must confirm this statement.": "U moet deze verklaring bevestigen.", "Your email...": "Jouw email...", "Your messages have been imported successfully!": "Je berichten zijn succesvol geïmporteerd!", - "Your session has expired. Please log in again.": "Je sessie is verlopen. Log opnieuw in." + "Your session has expired. Please log in again.": "Je sessie is verlopen. Log opnieuw in.", + "Storage": "Opslag", + "{{used}} / {{total}} used": "{{used}} / {{total}} gebruikt" } diff --git a/src/frontend/src/features/layouts/components/main/header/authenticated.tsx b/src/frontend/src/features/layouts/components/main/header/authenticated.tsx index c4ede6199..7160ee469 100644 --- a/src/frontend/src/features/layouts/components/main/header/authenticated.tsx +++ b/src/frontend/src/features/layouts/components/main/header/authenticated.tsx @@ -15,6 +15,7 @@ import { StatusEnum } from "@/features/api/gen"; import { CircularProgress } from "@/features/ui/components/circular-progress"; import { TaskImportCacheHelper } from "@/features/utils/task-import-cache"; import { useTheme } from "@/features/providers/theme"; +import { QuotaWidget } from "@/features/quota/components/quota-widget"; type AuthenticatedHeaderProps = HeaderProps & { @@ -65,6 +66,7 @@ export const HeaderRight = () => { return ( <> + {isDesktop && } | null; + mailbox: EntitlementsMailbox | null; + }; +} + +export function useEntitlements(mailboxId: string | undefined) { + return useQuery({ + queryKey: ["entitlements", mailboxId], + queryFn: () => + fetchAPI( + `/api/v1.0/entitlements/`, + { + params: mailboxId ? { mailbox_id: mailboxId } : undefined, + } + ), + enabled: !!mailboxId, + staleTime: 5 * 60 * 1000, // 5 minutes + meta: { + noGlobalError: true, + }, + }); +} diff --git a/src/frontend/src/features/quota/components/_index.scss b/src/frontend/src/features/quota/components/_index.scss new file mode 100644 index 000000000..beab77f0f --- /dev/null +++ b/src/frontend/src/features/quota/components/_index.scss @@ -0,0 +1,40 @@ +.quota-widget { + padding: 8px 16px; + border-top: 1px solid var(--c--contextuals--border--semantic--neutral--secondary); + + &__label { + font-size: 0.75rem; + font-weight: 600; + color: var(--c--contextuals--text--semantic--neutral--primary); + margin-bottom: 4px; + } + + &__bar { + width: 100%; + height: 6px; + background-color: var(--c--contextuals--background--semantic--neutral--secondary); + border-radius: 50vw; + overflow: hidden; + margin-bottom: 4px; + + &__fill { + height: 100%; + background-color: var(--c--contextuals--background--semantic--brand--primary); + border-radius: 50vw; + transition: width 0.3s ease; + + &[data-warning] { + background-color: var(--c--contextuals--background--semantic--warning--primary); + } + + &[data-critical] { + background-color: var(--c--contextuals--background--semantic--danger--primary); + } + } + } + + &__text { + font-size: 0.7rem; + color: var(--c--contextuals--text--semantic--neutral--secondary); + } +} diff --git a/src/frontend/src/features/quota/components/quota-widget.tsx b/src/frontend/src/features/quota/components/quota-widget.tsx new file mode 100644 index 000000000..72c0f1fe3 --- /dev/null +++ b/src/frontend/src/features/quota/components/quota-widget.tsx @@ -0,0 +1,53 @@ +import { useTranslation } from "react-i18next"; +import { useMailboxContext } from "@/features/providers/mailbox"; +import { useEntitlements } from "@/features/quota/api/use-entitlements"; + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} + +export const QuotaWidget = () => { + const { t } = useTranslation(); + const { selectedMailbox } = useMailboxContext(); + const { data, isError } = useEntitlements(selectedMailbox?.id); + + const mailboxData = data?.data?.mailbox; + + // Don't render if no mailbox is selected + if (!selectedMailbox) return null; + + // Don't render if entitlements fetch failed + if (isError) return null; + + // Don't render if no storage data (e.g. dummy backend) + if (!mailboxData || mailboxData.max_storage === null) return null; + + const maxStorage = mailboxData.max_storage; + const storageUsed = mailboxData.storage_used ?? 0; + const percentage = maxStorage > 0 ? Math.min((storageUsed / maxStorage) * 100, 100) : 0; + + return ( +
+
+ {t("Storage")} +
+
+
80 ? "" : undefined} + data-critical={percentage > 95 ? "" : undefined} + /> +
+
+ {t("{{used}} / {{total}} used", { + used: formatBytes(storageUsed), + total: formatBytes(maxStorage), + })} +
+
+ ); +}; diff --git a/src/frontend/src/styles/main.scss b/src/frontend/src/styles/main.scss index 264944ffd..7225625af 100644 --- a/src/frontend/src/styles/main.scss +++ b/src/frontend/src/styles/main.scss @@ -57,6 +57,7 @@ @use "./../features/forms/components/combobox"; @use "./../features/layouts/components/mailbox-panel/components/mailbox-labels/components/label-form-modal/components/color-palette-field"; @use "./../features/layouts/components/admin/signatures-view"; +@use "./../features/quota/components"; // Blocknote custom blocks and components @use "./../features/blocknote/blocknote-view-field"; From ba59d3055b832a7fef9d720d49b0575a6ca80dae Mon Sep 17 00:00:00 2001 From: Sylvain Zimmer Date: Thu, 19 Feb 2026 23:54:15 +0100 Subject: [PATCH 2/2] partial review fixes --- docs/entitlements.md | 34 +---- src/backend/core/authentication/backends.py | 22 +++ .../entitlements/backends/deploycenter.py | 12 +- src/backend/core/entitlements/middleware.py | 76 ----------- .../core/tests/entitlements/test_backends.py | 39 ++++-- .../core/tests/entitlements/test_cache.py | 1 - .../tests/entitlements/test_middleware.py | 129 ------------------ .../core/tests/entitlements/test_oidc_sync.py | 74 ++++++++-- src/backend/messages/settings.py | 1 - 9 files changed, 132 insertions(+), 256 deletions(-) delete mode 100644 src/backend/core/entitlements/middleware.py delete mode 100644 src/backend/core/tests/entitlements/test_middleware.py diff --git a/docs/entitlements.md b/docs/entitlements.md index 257d252d0..b94e17d94 100644 --- a/docs/entitlements.md +++ b/docs/entitlements.md @@ -32,12 +32,12 @@ The entitlements system provides a pluggable backend architecture for checking u - **Abstract base** (`core/entitlements/backends/base.py`): Defines the `EntitlementsBackend` interface. - **Dummy backend** (`core/entitlements/backends/dummy.py`): Always grants access, returns no storage info. - **DeployCenter backend** (`core/entitlements/backends/deploycenter.py`): Calls the DeployCenter API. -- **Middleware** (`core/entitlements/middleware.py`): Enforces `can_access` on all API requests. +- **OIDC access check** (`core/authentication/backends.py`): Enforces `can_access` at login time. - **API endpoint** (`core/api/viewsets/entitlements.py`): `GET /api/v1.0/entitlements/` for the frontend. ### Error Handling -All backend methods raise `EntitlementsUnavailableError` on failure. The system is **fail-closed**: if the entitlements service is unavailable, users cannot access the API (503 response). +All backend methods raise `EntitlementsUnavailableError` on failure. The access check at login is **fail-open**: if the entitlements service is unavailable during OIDC login, the user is allowed in. The entitlements API endpoint returns 503 if the service is unavailable. ## Configuration @@ -100,7 +100,7 @@ GET {base_url}/api/v1.0/entitlements?service_id=X&account_type=X&account_id=X ``` Headers: -- `X-Service-Auth: ApiKey {api_key}` +- `X-Service-Auth: Bearer {api_key}` - `Authorization: Bearer {access_token}` (if provided) ### User Entitlements Request @@ -108,36 +108,14 @@ Headers: - `account_type=user` - `account_id=` -Response fields: `can_access`, `can_admin_maildomains`, `operator` +Response: `{"operator": {...}, "entitlements": {"can_access": bool, "can_admin_maildomains": [str], ...}}` ### Mailbox Entitlements Request - `account_type=mailbox` - `account_id=` -Response fields: `max_storage`, `storage_used` - -## Middleware - -`EntitlementsMiddleware` checks `can_access` for all authenticated API requests. - -### Excluded Paths - -The following paths are excluded from entitlements checks: -- `/api/v1.0/config/` -- `/api/v1.0/inbound/mta/` -- `/api/v1.0/mta/` -- `/api/v1.0/metrics/` -- `/api/v1.0/entitlements/` -- `/api/v1.0/prometheus/` - -### Behavior - -- **Unauthenticated requests**: Pass through (DRF handles 401) -- **Superusers**: Pass through -- **`can_access=True`**: Pass through -- **`can_access=False`**: 403 Forbidden -- **Service unavailable**: 503 Service Unavailable +Response: `{"operator": {...}, "entitlements": {"max_storage": int, "storage_used": int, ...}}` ## OIDC Login Integration @@ -148,6 +126,8 @@ During OIDC login (`post_get_or_create_user`), the system: - Creates missing admin accesses for entitled domains - Removes admin accesses for domains not in the entitled list 3. If `can_admin_maildomains` is `None` (e.g. dummy backend), sync is skipped entirely +4. Checks `can_access` and denies login if `False` (raises `SuspiciousOperation`) + - If the entitlements service is unavailable, login is allowed (fail-open) ### Deployment Consideration diff --git a/src/backend/core/authentication/backends.py b/src/backend/core/authentication/backends.py index 4bd4b9abe..59042401e 100644 --- a/src/backend/core/authentication/backends.py +++ b/src/backend/core/authentication/backends.py @@ -90,6 +90,28 @@ def post_get_or_create_user(self, user, claims, _user_created): if user: self.autojoin_mailbox(user) self._sync_entitlements(user) + self._check_can_access(user) + + def _check_can_access(self, user): + """Check if the user has access to the app via entitlements. + + Called after _sync_entitlements which populates the cache. + Raises SuspiciousOperation to deny login if can_access is False. + """ + from core.entitlements import ( + EntitlementsUnavailableError, + get_user_entitlements, + ) + + try: + entitlements = get_user_entitlements(user.sub, user.email) + except EntitlementsUnavailableError: + # Fail open at login: if entitlements service is down, + # allow login (sync already logged the error) + return + + if not entitlements.get("can_access", False): + raise SuspiciousOperation(_("Access denied by entitlements policy")) def _sync_entitlements(self, user): """Fetch user entitlements and sync MailDomainAccess ADMIN records.""" diff --git a/src/backend/core/entitlements/backends/deploycenter.py b/src/backend/core/entitlements/backends/deploycenter.py index 6800d85f6..1674f71aa 100644 --- a/src/backend/core/entitlements/backends/deploycenter.py +++ b/src/backend/core/entitlements/backends/deploycenter.py @@ -32,7 +32,7 @@ def _make_request(self, account_type, account_id, access_token=None): "account_id": account_id, } headers = { - "X-Service-Auth": f"ApiKey {self.api_key}", + "X-Service-Auth": f"Bearer {self.api_key}", } if access_token: headers["Authorization"] = f"Bearer {access_token}" @@ -65,9 +65,10 @@ def get_user_entitlements(self, user_sub, user_email, access_token=None): raise EntitlementsUnavailableError( "Failed to fetch user entitlements from DeployCenter" ) + entitlements = data.get("entitlements", {}) return { - "can_access": data.get("can_access", False), - "can_admin_maildomains": data.get("can_admin_maildomains", []), + "can_access": entitlements.get("can_access", False), + "can_admin_maildomains": entitlements.get("can_admin_maildomains", []), "operator": data.get("operator"), } @@ -84,7 +85,8 @@ def get_mailbox_entitlements(self, mailbox_email, access_token=None): raise EntitlementsUnavailableError( "Failed to fetch mailbox entitlements from DeployCenter" ) + entitlements = data.get("entitlements", {}) return { - "max_storage": data.get("max_storage"), - "storage_used": data.get("storage_used"), + "max_storage": entitlements.get("max_storage"), + "storage_used": entitlements.get("storage_used"), } diff --git a/src/backend/core/entitlements/middleware.py b/src/backend/core/entitlements/middleware.py deleted file mode 100644 index 7070c073c..000000000 --- a/src/backend/core/entitlements/middleware.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Middleware to enforce entitlements-based access control on API requests.""" - -import logging -import re - -from django.conf import settings -from django.http import JsonResponse - -from core.entitlements import ( - EntitlementsUnavailableError, - get_user_entitlements, -) - -logger = logging.getLogger(__name__) - -# Paths that should be excluded from entitlements checks -EXCLUDED_PATH_PATTERNS = [ - re.compile(rf"^/api/{settings.API_VERSION}/config/"), - re.compile(rf"^/api/{settings.API_VERSION}/inbound/mta/"), - re.compile(rf"^/api/{settings.API_VERSION}/mta/"), - re.compile(rf"^/api/{settings.API_VERSION}/metrics/"), - re.compile(rf"^/api/{settings.API_VERSION}/entitlements/"), - re.compile(rf"^/api/{settings.API_VERSION}/prometheus/"), -] - - -class EntitlementsMiddleware: - """Middleware that checks can_access entitlement for authenticated API requests. - - - Skips non-API paths - - Skips excluded paths (config, MTA, metrics, entitlements, prometheus) - - Skips unauthenticated requests and superusers - - Returns 403 if can_access is False - - Returns 503 if entitlements service is unavailable (fail closed) - """ - - def __init__(self, get_response): - self.get_response = get_response - - def __call__(self, request): - # Only check API paths - if not request.path.startswith(f"/api/{settings.API_VERSION}/"): - return self.get_response(request) - - # Skip excluded paths - for pattern in EXCLUDED_PATH_PATTERNS: - if pattern.match(request.path): - return self.get_response(request) - - # Skip unauthenticated requests (they'll get 401 from DRF) - if not hasattr(request, "user") or not request.user.is_authenticated: - return self.get_response(request) - - # Skip superusers - if request.user.is_superuser: - return self.get_response(request) - - try: - entitlements = get_user_entitlements( - request.user.sub, request.user.email - ) - except EntitlementsUnavailableError: - logger.warning( - "Entitlements service unavailable for user %s", - request.user.sub, - ) - return JsonResponse( - {"detail": "Entitlements service unavailable"}, status=503 - ) - - if not entitlements.get("can_access", False): - return JsonResponse( - {"detail": "Access denied by entitlements policy"}, status=403 - ) - - return self.get_response(request) diff --git a/src/backend/core/tests/entitlements/test_backends.py b/src/backend/core/tests/entitlements/test_backends.py index d09b05804..603aba8bf 100644 --- a/src/backend/core/tests/entitlements/test_backends.py +++ b/src/backend/core/tests/entitlements/test_backends.py @@ -1,6 +1,7 @@ """Unit tests for entitlements backends.""" import pytest +import requests import responses from core.entitlements import EntitlementsUnavailableError @@ -48,8 +49,10 @@ def test_get_user_entitlements_success(self): responses.GET, "https://deploycenter.example.com/api/v1.0/entitlements", json={ - "can_access": True, - "can_admin_maildomains": ["example.com", "test.org"], + "entitlements": { + "can_access": True, + "can_admin_maildomains": ["example.com", "test.org"], + }, "operator": {"name": "Test Operator"}, }, status=200, @@ -71,7 +74,7 @@ def test_get_user_entitlements_success(self): assert "service_id=test-service" in request.url assert "account_type=user" in request.url assert "account_id=user%40example.com" in request.url - assert request.headers["X-Service-Auth"] == "ApiKey test-api-key" + assert request.headers["X-Service-Auth"] == "Bearer test-api-key" assert request.headers["Authorization"] == "Bearer test-token" @responses.activate @@ -79,7 +82,7 @@ def test_get_user_entitlements_no_access_token(self): responses.add( responses.GET, "https://deploycenter.example.com/api/v1.0/entitlements", - json={"can_access": True, "can_admin_maildomains": []}, + json={"entitlements": {"can_access": True, "can_admin_maildomains": []}}, status=200, ) @@ -94,7 +97,7 @@ def test_get_user_entitlements_can_access_false(self): responses.add( responses.GET, "https://deploycenter.example.com/api/v1.0/entitlements", - json={"can_access": False, "can_admin_maildomains": []}, + json={"entitlements": {"can_access": False, "can_admin_maildomains": []}}, status=200, ) @@ -119,7 +122,7 @@ def test_get_user_entitlements_timeout_raises(self): responses.add( responses.GET, "https://deploycenter.example.com/api/v1.0/entitlements", - body=ConnectionError("Connection timed out"), + body=requests.exceptions.ConnectionError("Connection timed out"), ) backend = self._get_backend() @@ -132,8 +135,10 @@ def test_get_mailbox_entitlements_success(self): responses.GET, "https://deploycenter.example.com/api/v1.0/entitlements", json={ - "max_storage": 5368709120, - "storage_used": 1073741824, + "entitlements": { + "max_storage": 5368709120, + "storage_used": 1073741824, + }, }, status=200, ) @@ -165,6 +170,24 @@ def test_get_mailbox_entitlements_server_error_raises(self): @responses.activate def test_get_user_entitlements_missing_fields_defaults(self): """Backend should provide sensible defaults for missing response fields.""" + responses.add( + responses.GET, + "https://deploycenter.example.com/api/v1.0/entitlements", + json={"entitlements": {}}, + status=200, + ) + + backend = self._get_backend() + result = backend.get_user_entitlements("user-sub", "user@example.com") + assert result == { + "can_access": False, + "can_admin_maildomains": [], + "operator": None, + } + + @responses.activate + def test_get_user_entitlements_missing_entitlements_key(self): + """Backend should handle response with no entitlements key.""" responses.add( responses.GET, "https://deploycenter.example.com/api/v1.0/entitlements", diff --git a/src/backend/core/tests/entitlements/test_cache.py b/src/backend/core/tests/entitlements/test_cache.py index 1887d05a0..ba905c115 100644 --- a/src/backend/core/tests/entitlements/test_cache.py +++ b/src/backend/core/tests/entitlements/test_cache.py @@ -4,7 +4,6 @@ import pytest from django.core.cache import cache -from django.test import override_settings from core.entitlements import ( EntitlementsUnavailableError, diff --git a/src/backend/core/tests/entitlements/test_middleware.py b/src/backend/core/tests/entitlements/test_middleware.py deleted file mode 100644 index 0c8272099..000000000 --- a/src/backend/core/tests/entitlements/test_middleware.py +++ /dev/null @@ -1,129 +0,0 @@ -"""Tests for the entitlements middleware.""" - -from unittest import mock - -import pytest -from django.core.cache import cache -from rest_framework.test import APIClient - -from core import factories -from core.entitlements import EntitlementsUnavailableError - -pytestmark = pytest.mark.django_db - - -@pytest.fixture(autouse=True) -def _clear_cache(): - cache.clear() - yield - cache.clear() - - -@pytest.fixture -def api_client(): - return APIClient() - - -class TestEntitlementsMiddleware: - """Tests for the EntitlementsMiddleware.""" - - def test_allows_unauthenticated_requests(self, api_client): - """Unauthenticated requests should pass through (DRF handles 401).""" - response = api_client.get("/api/v1.0/config/") - # Config endpoint allows any - should pass through - assert response.status_code == 200 - - def test_skips_non_api_paths(self, api_client): - """Non-API paths should not be checked.""" - response = api_client.get("/admin/") - # May get redirect or 404, but not 403/503 from middleware - assert response.status_code != 403 - assert response.status_code != 503 - - def test_skips_config_endpoint(self, api_client): - """Config endpoint should be excluded from entitlements checks.""" - response = api_client.get("/api/v1.0/config/") - assert response.status_code == 200 - - @mock.patch("core.entitlements.middleware.get_user_entitlements") - def test_skips_entitlements_endpoint(self, mock_get, api_client): - """Entitlements endpoint itself should be excluded.""" - user = factories.UserFactory() - api_client.force_authenticate(user=user) - - mock_get.return_value = { - "can_access": True, - "can_admin_maildomains": [], - "operator": None, - } - - response = api_client.get("/api/v1.0/entitlements/") - # The middleware should not call get_user_entitlements for this path - mock_get.assert_not_called() - - @mock.patch("core.entitlements.middleware.get_user_entitlements") - def test_skips_superusers(self, mock_get, api_client): - """Superusers should bypass entitlements checks.""" - user = factories.UserFactory(is_superuser=True) - api_client.force_authenticate(user=user) - - response = api_client.get("/api/v1.0/users/") - mock_get.assert_not_called() - - @mock.patch("core.entitlements.middleware.get_user_entitlements") - def test_allows_access_when_can_access_true(self, mock_get, api_client): - """User with can_access=True should be allowed.""" - user = factories.UserFactory() - api_client.force_authenticate(user=user) - - mock_get.return_value = { - "can_access": True, - "can_admin_maildomains": [], - "operator": None, - } - - response = api_client.get("/api/v1.0/users/") - # Should pass through middleware (may get whatever the view returns) - assert response.status_code != 403 - assert response.status_code != 503 - - @mock.patch("core.entitlements.middleware.get_user_entitlements") - def test_blocks_access_when_can_access_false(self, mock_get, api_client): - """User with can_access=False should get 403.""" - user = factories.UserFactory() - api_client.force_authenticate(user=user) - - mock_get.return_value = { - "can_access": False, - "can_admin_maildomains": [], - "operator": None, - } - - response = api_client.get("/api/v1.0/users/") - assert response.status_code == 403 - assert response.json()["detail"] == "Access denied by entitlements policy" - - @mock.patch("core.entitlements.middleware.get_user_entitlements") - def test_returns_503_on_unavailable_error(self, mock_get, api_client): - """Should return 503 when entitlements service is unavailable.""" - user = factories.UserFactory() - api_client.force_authenticate(user=user) - - mock_get.side_effect = EntitlementsUnavailableError("Backend down") - - response = api_client.get("/api/v1.0/users/") - assert response.status_code == 503 - assert response.json()["detail"] == "Entitlements service unavailable" - - @mock.patch("core.entitlements.middleware.get_user_entitlements") - def test_skips_mta_endpoint(self, mock_get, api_client): - """MTA endpoints should be excluded from entitlements checks.""" - # This endpoint requires special auth, so we won't get 403 from middleware - response = api_client.post("/api/v1.0/mta/check-recipients/") - mock_get.assert_not_called() - - @mock.patch("core.entitlements.middleware.get_user_entitlements") - def test_skips_metrics_endpoint(self, mock_get, api_client): - """Metrics endpoints should be excluded from entitlements checks.""" - response = api_client.get("/api/v1.0/metrics/maildomain_users/") - mock_get.assert_not_called() diff --git a/src/backend/core/tests/entitlements/test_oidc_sync.py b/src/backend/core/tests/entitlements/test_oidc_sync.py index 093bb57ff..317a7b49c 100644 --- a/src/backend/core/tests/entitlements/test_oidc_sync.py +++ b/src/backend/core/tests/entitlements/test_oidc_sync.py @@ -1,9 +1,10 @@ -"""Tests for entitlements sync during OIDC login.""" +"""Tests for entitlements sync and access check during OIDC login.""" from unittest import mock import pytest from django.core.cache import cache +from django.core.exceptions import SuspiciousOperation from core import factories from core.authentication.backends import OIDCAuthenticationBackend @@ -24,7 +25,7 @@ def _clear_cache(): class TestOIDCSyncEntitlements: """Tests for _sync_entitlements called during OIDC login.""" - @mock.patch("core.authentication.backends.get_user_entitlements") + @mock.patch("core.entitlements.get_user_entitlements") def test_creates_admin_access_for_entitled_domains(self, mock_get): user = factories.UserFactory() domain1 = factories.MailDomainFactory(name="domain1.com") @@ -46,7 +47,7 @@ def test_creates_admin_access_for_entitled_domains(self, mock_get): user=user, maildomain=domain2, role=MailDomainAccessRoleChoices.ADMIN ).exists() - @mock.patch("core.authentication.backends.get_user_entitlements") + @mock.patch("core.entitlements.get_user_entitlements") def test_removes_stale_admin_access(self, mock_get): user = factories.UserFactory() domain1 = factories.MailDomainFactory(name="domain1.com") @@ -77,7 +78,7 @@ def test_removes_stale_admin_access(self, mock_get): user=user, maildomain=domain2 ).exists() - @mock.patch("core.authentication.backends.get_user_entitlements") + @mock.patch("core.entitlements.get_user_entitlements") def test_skips_nonexistent_domains(self, mock_get): user = factories.UserFactory() @@ -92,7 +93,7 @@ def test_skips_nonexistent_domains(self, mock_get): assert MailDomainAccess.objects.filter(user=user).count() == 0 - @mock.patch("core.authentication.backends.get_user_entitlements") + @mock.patch("core.entitlements.get_user_entitlements") def test_handles_unavailable_error(self, mock_get): """On EntitlementsUnavailableError, sync should be skipped without crash.""" user = factories.UserFactory() @@ -109,7 +110,7 @@ def test_handles_unavailable_error(self, mock_get): # Existing access should NOT be removed assert MailDomainAccess.objects.filter(user=user, maildomain=domain).exists() - @mock.patch("core.authentication.backends.get_user_entitlements") + @mock.patch("core.entitlements.get_user_entitlements") def test_skips_sync_when_field_not_in_response(self, mock_get): """If can_admin_maildomains is None (e.g. dummy backend), skip sync entirely.""" user = factories.UserFactory() @@ -130,7 +131,7 @@ def test_skips_sync_when_field_not_in_response(self, mock_get): # Existing access should still be there assert MailDomainAccess.objects.filter(user=user, maildomain=domain).exists() - @mock.patch("core.authentication.backends.get_user_entitlements") + @mock.patch("core.entitlements.get_user_entitlements") def test_empty_list_removes_all_admin_accesses(self, mock_get): """An empty list means the user has no admin access to any domain.""" user = factories.UserFactory() @@ -150,7 +151,7 @@ def test_empty_list_removes_all_admin_accesses(self, mock_get): assert MailDomainAccess.objects.filter(user=user).count() == 0 - @mock.patch("core.authentication.backends.get_user_entitlements") + @mock.patch("core.entitlements.get_user_entitlements") def test_does_not_duplicate_existing_access(self, mock_get): """Should not create duplicate MailDomainAccess records.""" user = factories.UserFactory() @@ -170,7 +171,7 @@ def test_does_not_duplicate_existing_access(self, mock_get): assert MailDomainAccess.objects.filter(user=user, maildomain=domain).count() == 1 - @mock.patch("core.authentication.backends.get_user_entitlements") + @mock.patch("core.entitlements.get_user_entitlements") def test_force_refresh_is_used(self, mock_get): """Should call get_user_entitlements with force_refresh=True.""" user = factories.UserFactory() @@ -187,3 +188,58 @@ def test_force_refresh_is_used(self, mock_get): mock_get.assert_called_once_with( user.sub, user.email, force_refresh=True ) + + +class TestOIDCCheckCanAccess: + """Tests for _check_can_access called during OIDC login.""" + + @mock.patch("core.entitlements.get_user_entitlements") + def test_allows_access_when_can_access_true(self, mock_get): + user = factories.UserFactory() + mock_get.return_value = { + "can_access": True, + "can_admin_maildomains": [], + "operator": None, + } + + backend = OIDCAuthenticationBackend() + # Should not raise + backend._check_can_access(user) + + @mock.patch("core.entitlements.get_user_entitlements") + def test_denies_access_when_can_access_false(self, mock_get): + user = factories.UserFactory() + mock_get.return_value = { + "can_access": False, + "can_admin_maildomains": [], + "operator": None, + } + + backend = OIDCAuthenticationBackend() + with pytest.raises(SuspiciousOperation, match="Access denied"): + backend._check_can_access(user) + + @mock.patch("core.entitlements.get_user_entitlements") + def test_fails_open_when_service_unavailable(self, mock_get): + """If entitlements service is down, allow login (fail open).""" + user = factories.UserFactory() + mock_get.side_effect = EntitlementsUnavailableError("Backend down") + + backend = OIDCAuthenticationBackend() + # Should not raise - fail open at login + backend._check_can_access(user) + + @mock.patch("core.entitlements.get_user_entitlements") + def test_uses_cached_entitlements(self, mock_get): + """Should call get_user_entitlements without force_refresh (uses cache from sync).""" + user = factories.UserFactory() + mock_get.return_value = { + "can_access": True, + "can_admin_maildomains": [], + "operator": None, + } + + backend = OIDCAuthenticationBackend() + backend._check_can_access(user) + + mock_get.assert_called_once_with(user.sub, user.email) diff --git a/src/backend/messages/settings.py b/src/backend/messages/settings.py index 8f4c11c68..aa32da3d9 100755 --- a/src/backend/messages/settings.py +++ b/src/backend/messages/settings.py @@ -484,7 +484,6 @@ class Base(Configuration): "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", - "core.entitlements.middleware.EntitlementsMiddleware", "django.contrib.messages.middleware.MessageMiddleware", ]