(entitlements) add Entitlements backend with Deploy Center support (#31)

This checks if the user has access to the app and can create calendars.
This commit is contained in:
Sylvain Zimmer
2026-03-06 02:47:03 +01:00
committed by GitHub
parent 5e0506d64b
commit cd2b15b3b5
26 changed files with 1312 additions and 120 deletions

View File

@@ -9,32 +9,6 @@ on:
- "*" - "*"
jobs: jobs:
check-changelog:
runs-on: ubuntu-latest
if: |
contains(github.event.pull_request.labels.*.name, 'noChangeLog') == false &&
github.event_name == 'pull_request'
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 50
- name: Check that the CHANGELOG has been modified in the current branch
run: git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.event.after }} | grep 'CHANGELOG.md'
lint-changelog:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Check CHANGELOG max line length
run: |
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
if [ $max_line_length -ge 80 ]; then
echo "ERROR: CHANGELOG has lines longer than 80 characters."
exit 1
fi
lint-back: lint-back:
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults: defaults:

0
docker/sabredav/init-database.sh Normal file → Executable file
View File

246
docs/entitlements.md Normal file
View File

@@ -0,0 +1,246 @@
# Entitlements System
The entitlements system provides a pluggable backend architecture for
checking whether a user is allowed to access the application. It
integrates with the DeployCenter API in production and uses a local
backend for development.
Unlike La Suite Messages, Calendars only checks `can_access` — there
is no admin permission sync.
## Architecture
```
┌─────────────────────────────────────────────┐
│ OIDC Authentication Backend │
│ post_get_or_create_user() — warms cache │
└──────────────┬──────────────────────────────┘
┌──────────────▼──────────────────────────────┐
│ UserMeSerializer │
│ GET /users/me/ → { can_access: bool } │
└──────────────┬──────────────────────────────┘
┌──────────────▼──────────────────────────────┐
│ Service Layer │
│ get_user_entitlements() │
└──────────────┬──────────────────────────────┘
┌──────────────▼──────────────────────────────┐
│ Backend Factory (singleton) │
│ get_entitlements_backend() │
└──────────────┬──────────────────────────────┘
┌───────┴───────┐
│ │
┌──────▼─────┐ ┌──────▼───────────────┐
│ Local │ │ DeployCenter │
│ Backend │ │ Backend │
│ (dev/test) │ │ (production, cached) │
└────────────┘ └──────────────────────┘
```
### Components
- **Service layer** (`core/entitlements/__init__.py`): Public
`get_user_entitlements()` function and
`EntitlementsUnavailableError` exception.
- **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.
- **Local backend** (`core/entitlements/backends/local.py`): Always
grants access. Used for local development.
- **DeployCenter backend**
(`core/entitlements/backends/deploycenter.py`): Calls the
DeployCenter API with Django cache and stale fallback.
### Integration points
1. **OIDC login** (`core/authentication/backends.py`):
`post_get_or_create_user()` calls `get_user_entitlements()` with
`force_refresh=True` to warm the cache. Login always succeeds
regardless of `can_access` value — access is gated at API level
and in the frontend.
2. **User API** (`core/api/serializers.py`): `UserMeSerializer`
exposes `can_access` as a field on `GET /users/me/`. Fail-open:
returns `True` when entitlements are unavailable.
3. **Default calendar creation** (`core/signals.py`):
`provision_default_calendar` checks entitlements before creating a
calendar for a new user. Fail-closed: skips creation when
entitlements are unavailable.
4. **CalDAV proxy** (`core/api/viewsets_caldav.py`): Blocks
`MKCALENDAR` and `MKCOL` methods for non-entitled users.
Other methods (PROPFIND, REPORT, GET, PUT, DELETE) are allowed
so that users invited to shared calendars can still use them.
Fail-closed: denies creation when entitlements are unavailable.
5. **Import events** (`core/api/viewsets.py`): Blocks
`POST /calendars/import-events/` for non-entitled users.
Fail-closed: denies import when entitlements are unavailable.
6. **Frontend** (`pages/index.tsx`, `pages/calendar.tsx`): Checks
`user.can_access` and redirects to `/no-access` when `false`.
Calendar creation uses MKCALENDAR via CalDAV proxy (no Django
endpoint).
### Error handling
- **Login is fail-open**: if the entitlements service is unavailable,
login succeeds and the cache warming is skipped.
- **User API is fail-open**: if the entitlements service is
unavailable, `can_access` defaults to `True`.
- **Calendar creation is fail-closed**: if the entitlements service
is unavailable, the default calendar is not created (avoids
provisioning resources for users who may not be entitled).
- **CalDAV proxy MKCALENDAR/MKCOL is fail-closed**: if the
entitlements service is unavailable, calendar creation via CalDAV
is denied (returns 403).
- **Import events is fail-closed**: if the entitlements service is
unavailable, ICS import is denied (returns 403).
- The DeployCenter backend falls back to stale cached data when the
API is unavailable.
- `EntitlementsUnavailableError` is only raised when the API fails
**and** no cache exists.
## Configuration
### Environment variables
| Variable | Default | Description |
|---|---|---|
| `ENTITLEMENTS_BACKEND` | `core.entitlements.backends.local.LocalEntitlementsBackend` | Python import path of the backend class |
| `ENTITLEMENTS_BACKEND_PARAMETERS` | `{}` | JSON object 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/api/v1.0/entitlements/",
"service_id": "calendar",
"api_key": "your-api-key",
"timeout": 10,
"oidc_claims": ["siret"]
}
```
| Parameter | Required | Description |
|---|---|---|
| `base_url` | Yes | Full URL of the DeployCenter entitlements endpoint |
| `service_id` | Yes | Service identifier in DeployCenter |
| `api_key` | Yes | API key for `X-Service-Auth: Bearer` header |
| `timeout` | No | HTTP timeout in seconds (default: 10) |
| `oidc_claims` | No | OIDC claim names to forward as query params |
### Example production configuration
```bash
ENTITLEMENTS_BACKEND=core.entitlements.backends.deploycenter.DeployCenterEntitlementsBackend
ENTITLEMENTS_BACKEND_PARAMETERS='{"base_url":"https://deploycenter.example.com/api/v1.0/entitlements/","service_id":"calendar","api_key":"secret","timeout":10,"oidc_claims":["siret"]}'
ENTITLEMENTS_CACHE_TIMEOUT=300
```
## Backend interface
Custom backends must extend `EntitlementsBackend` and implement:
```python
class MyBackend(EntitlementsBackend):
def __init__(self, **kwargs):
# Receives ENTITLEMENTS_BACKEND_PARAMETERS as kwargs
pass
def get_user_entitlements(
self, user_sub, user_email, user_info=None, force_refresh=False
):
# Return: {"can_access": bool}
# Raise EntitlementsUnavailableError on failure.
pass
```
## DeployCenter API
The DeployCenter backend calls:
```
GET {base_url}?service_id=X&account_type=user&account_email=X
```
Headers: `X-Service-Auth: Bearer {api_key}`
Query parameters include any configured `oidc_claims` extracted from
the OIDC user_info response (e.g. `siret`).
Expected response: `{"entitlements": {"can_access": true}}`
## Access control flow
The entitlements check follows a two-step approach: the backend
exposes entitlements data, and the frontend gates access.
### On login
1. User authenticates via OIDC — login always succeeds
2. `post_get_or_create_user` calls `get_user_entitlements()` with
`force_refresh=True` to warm the cache
3. If entitlements are unavailable, a warning is logged but login
proceeds
### On page load
1. Frontend calls `GET /users/me/` which includes `can_access`
2. If `can_access` is `false`, the frontend redirects to `/no-access`
3. The user remains authenticated — they see the header, logo, and
their profile, but cannot use the app
4. The `/no-access` page offers a logout button and a message to
contact support
This approach ensures users always have a session (important for
shared calendars and other interactions) while still gating access to
the main application.
### Caching behavior
- The DeployCenter backend caches results in Django's cache framework
(key: `entitlements:user:{user_sub}`, TTL:
`ENTITLEMENTS_CACHE_TIMEOUT`).
- On login, `force_refresh=True` bypasses the cache for fresh data.
- If the API fails during a forced refresh, stale cached data is
returned as fallback.
- Subsequent `GET /users/me/` calls use the cached value (no
`force_refresh`).
## Frontend
Users denied access see `/no-access` — a page using the main layout
(header with logo and user profile visible) with:
- A message explaining the app is not available for their account
- A suggestion to contact support
- A logout button
The user is fully authenticated and can see their profile in the
header, but cannot access calendars or events.
## Key files
| Area | Path |
|------|------|
| Service layer | `src/backend/core/entitlements/__init__.py` |
| Backend factory | `src/backend/core/entitlements/factory.py` |
| Abstract base | `src/backend/core/entitlements/backends/base.py` |
| Local backend | `src/backend/core/entitlements/backends/local.py` |
| DeployCenter backend | `src/backend/core/entitlements/backends/deploycenter.py` |
| Auth integration | `src/backend/core/authentication/backends.py` |
| User API serializer | `src/backend/core/api/serializers.py` |
| Calendar gating (signal) | `src/backend/core/signals.py` |
| CalDAV proxy gating | `src/backend/core/api/viewsets_caldav.py` |
| Import events gating | `src/backend/core/api/viewsets.py` |
| No-access page | `src/frontend/apps/calendars/src/pages/no-access.tsx` |
| Homepage gate | `src/frontend/apps/calendars/src/pages/index.tsx` |
| Calendar gate | `src/frontend/apps/calendars/src/pages/calendar.tsx` |
| Tests | `src/backend/core/tests/test_entitlements.py` |

View File

@@ -1,25 +0,0 @@
{
"extends": ["github>suitenumerique/renovate-configuration"],
"dependencyDashboard": true,
"labels": ["dependencies", "noChangeLog"],
"packageRules": [
{
"groupName": "allowed redis versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["redis"],
"allowedVersions": "<6.0.0"
},
{
"groupName": "allowed pylint versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["pylint"],
"allowedVersions": "<4.0.0"
},
{
"description": "Disable requires-python updates - managed manually",
"matchManagers": ["pep621"],
"matchPackageNames": ["python"],
"enabled": false
}
]
}

View File

@@ -133,6 +133,23 @@ class Base(Configuration):
environ_prefix=None, 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.IntegerValue(
300,
environ_name="ENTITLEMENTS_CACHE_TIMEOUT",
environ_prefix=None,
)
# Security # Security
ALLOWED_HOSTS = values.ListValue([]) ALLOWED_HOSTS = values.ListValue([])
SECRET_KEY = SecretFileValue(None) SECRET_KEY = SecretFileValue(None)

View File

@@ -1,9 +1,15 @@
"""Permission handlers for the calendars core app.""" """Permission handlers for the calendars core app."""
import logging
from django.core import exceptions from django.core import exceptions
from rest_framework import permissions from rest_framework import permissions
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
logger = logging.getLogger(__name__)
ACTION_FOR_METHOD_TO_PERMISSION = { ACTION_FOR_METHOD_TO_PERMISSION = {
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}, "versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"},
"children": {"GET": "children_list", "POST": "children_create"}, "children": {"GET": "children_list", "POST": "children_create"},
@@ -60,6 +66,23 @@ class IsOwnedOrPublic(IsAuthenticated):
return False return False
class IsEntitled(IsAuthenticated):
"""Allows access only to users with can_access entitlement.
Fail-closed: denies access when the entitlements service is
unavailable and no cached value exists.
"""
def has_permission(self, request, view):
if not super().has_permission(request, view):
return False
try:
entitlements = get_user_entitlements(request.user.sub, request.user.email)
return entitlements.get("can_access", True)
except EntitlementsUnavailableError:
return False
class AccessPermission(permissions.BasePermission): class AccessPermission(permissions.BasePermission):
"""Permission class for access objects.""" """Permission class for access objects."""

View File

@@ -7,6 +7,7 @@ from django.utils.translation import gettext_lazy as _
from rest_framework import exceptions, serializers from rest_framework import exceptions, serializers
from core import models from core import models
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
class UserLiteSerializer(serializers.ModelSerializer): class UserLiteSerializer(serializers.ModelSerializer):
@@ -108,18 +109,20 @@ class UserSerializer(serializers.ModelSerializer):
class UserMeSerializer(UserSerializer): class UserMeSerializer(UserSerializer):
"""Serialize users for me endpoint.""" """Serialize users for me endpoint."""
can_access = serializers.SerializerMethodField(read_only=True)
class Meta: class Meta:
model = models.User model = models.User
fields = UserSerializer.Meta.fields fields = [*UserSerializer.Meta.fields, "can_access"]
read_only_fields = UserSerializer.Meta.read_only_fields read_only_fields = [*UserSerializer.Meta.read_only_fields, "can_access"]
def get_can_access(self, user) -> bool:
class CalendarCreateSerializer(serializers.Serializer): # pylint: disable=abstract-method """Check entitlements for the current user."""
"""Serializer for creating a Calendar (CalDAV only, no Django model).""" try:
entitlements = get_user_entitlements(user.sub, user.email)
name = serializers.CharField(max_length=255) return entitlements.get("can_access", True)
color = serializers.CharField(max_length=7, required=False, default="") except EntitlementsUnavailableError:
description = serializers.CharField(required=False, default="") return True # fail-open
class CalendarSubscriptionTokenSerializer(serializers.ModelSerializer): class CalendarSubscriptionTokenSerializer(serializers.ModelSerializer):

View File

@@ -18,7 +18,6 @@ from rest_framework.throttling import UserRateThrottle
from core import models from core import models
from core.services.caldav_service import ( from core.services.caldav_service import (
CalendarService,
normalize_caldav_path, normalize_caldav_path,
verify_caldav_access, verify_caldav_access,
) )
@@ -266,34 +265,15 @@ class ConfigView(drf.views.APIView):
class CalendarViewSet(viewsets.GenericViewSet): class CalendarViewSet(viewsets.GenericViewSet):
"""ViewSet for calendar operations. """ViewSet for calendar operations.
create: Create a new calendar (CalDAV only, no Django record).
import_events: Import events from an ICS file. import_events: Import events from an ICS file.
""" """
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
serializer_class = serializers.CalendarCreateSerializer
def create(self, request): def get_permissions(self):
"""Create a new calendar via CalDAV. if self.action == "import_events":
return [permissions.IsEntitled()]
POST /api/v1.0/calendars/ return super().get_permissions()
Body: { name, color?, description? }
Returns: { caldav_path }
"""
serializer = serializers.CalendarCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
service = CalendarService()
caldav_path = service.create_calendar(
user=request.user,
name=serializer.validated_data["name"],
color=serializer.validated_data.get("color", ""),
)
return drf_response.Response(
{"caldav_path": caldav_path},
status=status.HTTP_201_CREATED,
)
@action( @action(
detail=False, detail=False,

View File

@@ -12,6 +12,7 @@ from django.views.decorators.csrf import csrf_exempt
import requests import requests
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
from core.services.caldav_service import CalDAVHTTPClient, validate_caldav_proxy_path from core.services.caldav_service import CalDAVHTTPClient, validate_caldav_proxy_path
from core.services.calendar_invitation_service import calendar_invitation_service from core.services.calendar_invitation_service import calendar_invitation_service
@@ -29,7 +30,28 @@ class CalDAVProxyView(View):
Authentication is handled via session cookies instead. Authentication is handled via session cookies instead.
""" """
def dispatch(self, request, *args, **kwargs): # noqa: PLR0912 # pylint: disable=too-many-branches @staticmethod
def _check_entitlements_for_creation(user):
"""Check if user is entitled to create calendars.
Returns None if allowed, or an HttpResponse(403) if denied.
Fail-closed: denies if the entitlements service is unavailable.
"""
try:
entitlements = get_user_entitlements(user.sub, user.email)
if not entitlements.get("can_access", True):
return HttpResponse(
status=403,
content="Calendar creation not allowed",
)
except EntitlementsUnavailableError:
return HttpResponse(
status=403,
content="Calendar creation not allowed",
)
return None
def dispatch(self, request, *args, **kwargs): # noqa: PLR0912, PLR0911, PLR0915 # pylint: disable=too-many-branches,too-many-return-statements,too-many-statements
"""Forward all HTTP methods to CalDAV server.""" """Forward all HTTP methods to CalDAV server."""
# Handle CORS preflight requests # Handle CORS preflight requests
if request.method == "OPTIONS": if request.method == "OPTIONS":
@@ -45,6 +67,13 @@ class CalDAVProxyView(View):
if not request.user.is_authenticated: if not request.user.is_authenticated:
return HttpResponse(status=401) return HttpResponse(status=401)
# Block calendar creation (MKCALENDAR/MKCOL) for non-entitled users.
# Other methods (GET, PROPFIND, REPORT, PUT, DELETE, etc.) are allowed
# so that users invited to shared calendars can still use them.
if request.method in ("MKCALENDAR", "MKCOL"):
if denied := self._check_entitlements_for_creation(request.user):
return denied
# Build the CalDAV server URL # Build the CalDAV server URL
path = kwargs.get("path", "") path = kwargs.get("path", "")

View File

@@ -4,12 +4,12 @@ import logging
from django.conf import settings from django.conf import settings
from django.core.exceptions import SuspiciousOperation from django.core.exceptions import SuspiciousOperation
from django.utils.translation import gettext_lazy as _
from lasuite.oidc_login.backends import ( from lasuite.oidc_login.backends import (
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend, OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
) )
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
from core.models import DuplicateEmailError from core.models import DuplicateEmailError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -51,3 +51,18 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
return self.UserModel.objects.get_user_by_sub_or_email(sub, email) return self.UserModel.objects.get_user_by_sub_or_email(sub, email)
except DuplicateEmailError as err: except DuplicateEmailError as err:
raise SuspiciousOperation(err.message) from err raise SuspiciousOperation(err.message) from err
def post_get_or_create_user(self, user, claims, is_new_user):
"""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:
logger.warning(
"Entitlements unavailable for %s during login",
user.email,
)

View File

@@ -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_access": 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
)

View File

@@ -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_access": bool}
Raises:
EntitlementsUnavailableError: If the backend cannot be reached.
"""

View File

@@ -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_access": entitlements.get("can_access", False),
}
cache.set(cache_key, result, settings.ENTITLEMENTS_CACHE_TIMEOUT)
return result

View File

@@ -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_access": True}

View File

@@ -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)

View File

@@ -9,6 +9,7 @@ from django.contrib.auth import get_user_model
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
from core.services.caldav_service import CalendarService from core.services.caldav_service import CalendarService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -27,6 +28,23 @@ def provision_default_calendar(sender, instance, created, **kwargs): # pylint:
if not settings.CALDAV_URL: if not settings.CALDAV_URL:
return return
# Check entitlements before creating calendar — fail-closed:
# never create a calendar if we can't confirm access.
try:
entitlements = get_user_entitlements(instance.sub, instance.email)
if not entitlements.get("can_access", True):
logger.info(
"Skipped calendar creation for %s (not entitled)",
instance.email,
)
return
except EntitlementsUnavailableError:
logger.warning(
"Entitlements unavailable for %s, skipping calendar creation",
instance.email,
)
return
try: try:
service = CalendarService() service = CalendarService()
service.create_default_calendar(instance) service.create_default_calendar(instance)

View File

@@ -262,6 +262,7 @@ def test_api_users_retrieve_me_authenticated():
"full_name": user.full_name, "full_name": user.full_name,
"short_name": user.short_name, "short_name": user.short_name,
"language": user.language, "language": user.language,
"can_access": True,
} }

View File

@@ -0,0 +1,670 @@
"""Tests for the entitlements module."""
from unittest import mock
from django.conf import settings
from django.test import override_settings
import pytest
import requests
import responses
from rest_framework.status import (
HTTP_200_OK,
HTTP_207_MULTI_STATUS,
HTTP_403_FORBIDDEN,
)
from rest_framework.test import APIClient
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
# -- LocalEntitlementsBackend --
def test_local_backend_always_grants_access():
"""The local backend should always return can_access=True."""
backend = LocalEntitlementsBackend()
result = backend.get_user_entitlements("sub-123", "user@example.com")
assert result == {"can_access": 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_access": 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_access"] is True
get_entitlements_backend.cache_clear()
# -- DeployCenterEntitlementsBackend --
DC_URL = "https://deploy.example.com/api/v1.0/entitlements/"
@responses.activate
def test_deploycenter_backend_grants_access():
"""DeployCenter backend should return can_access from API response."""
responses.add(
responses.GET,
DC_URL,
json={"entitlements": {"can_access": True}},
status=200,
)
backend = DeployCenterEntitlementsBackend(
base_url=DC_URL,
service_id="calendar",
api_key="test-key",
)
result = backend.get_user_entitlements("sub-123", "user@example.com")
assert result == {"can_access": True}
# Verify request was made with correct params and header
assert len(responses.calls) == 1
request = responses.calls[0].request
assert "service_id=calendar" 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_access=False when API says so."""
responses.add(
responses.GET,
DC_URL,
json={"entitlements": {"can_access": False}},
status=200,
)
backend = DeployCenterEntitlementsBackend(
base_url=DC_URL,
service_id="calendar",
api_key="test-key",
)
result = backend.get_user_entitlements("sub-123", "user@example.com")
assert result == {"can_access": 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_access": True}},
status=200,
)
backend = DeployCenterEntitlementsBackend(
base_url=DC_URL,
service_id="calendar",
api_key="test-key",
)
# First call hits the API
result1 = backend.get_user_entitlements("sub-123", "user@example.com")
assert result1 == {"can_access": True}
assert len(responses.calls) == 1
# Second call should use cache
result2 = backend.get_user_entitlements("sub-123", "user@example.com")
assert result2 == {"can_access": 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_access": True}},
status=200,
)
responses.add(
responses.GET,
DC_URL,
json={"entitlements": {"can_access": False}},
status=200,
)
backend = DeployCenterEntitlementsBackend(
base_url=DC_URL,
service_id="calendar",
api_key="test-key",
)
result1 = backend.get_user_entitlements("sub-123", "user@example.com")
assert result1["can_access"] is True
result2 = backend.get_user_entitlements(
"sub-123", "user@example.com", force_refresh=True
)
assert result2["can_access"] 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_access": True}},
status=200,
)
backend = DeployCenterEntitlementsBackend(
base_url=DC_URL,
service_id="calendar",
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_access": 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="calendar",
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_access": True}},
status=200,
)
backend = DeployCenterEntitlementsBackend(
base_url=DC_URL,
service_id="calendar",
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 --
pytestmark = pytest.mark.django_db
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_access": True},
) as mock_ent:
backend.post_get_or_create_user(user, {"sub": "x"}, is_new_user=False)
mock_ent.assert_called_once_with(
user_sub=user.sub,
user_email=user.email,
user_info={"sub": "x"},
force_refresh=True,
)
def test_auth_backend_login_succeeds_when_access_denied():
"""Login should succeed even when can_access is False (gated in frontend)."""
user = factories.UserFactory()
backend = OIDCAuthenticationBackend()
with mock.patch(
"core.authentication.backends.get_user_entitlements",
return_value={"can_access": False},
):
# Should not raise — user logs in, frontend gates access
backend.post_get_or_create_user(user, {}, 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, {}, is_new_user=False)
# -- UserMeSerializer (can_access field) --
def test_user_me_serializer_includes_can_access_true():
"""UserMeSerializer should include can_access=True when entitled."""
user = factories.UserFactory()
with mock.patch(
"core.api.serializers.get_user_entitlements",
return_value={"can_access": True},
):
data = UserMeSerializer(user).data
assert data["can_access"] is True
def test_user_me_serializer_includes_can_access_false():
"""UserMeSerializer should include can_access=False when not entitled."""
user = factories.UserFactory()
with mock.patch(
"core.api.serializers.get_user_entitlements",
return_value={"can_access": False},
):
data = UserMeSerializer(user).data
assert data["can_access"] is False
def test_user_me_serializer_can_access_fail_open():
"""UserMeSerializer should return can_access=True 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_access"] is True
# -- Signals integration --
@override_settings(
CALDAV_URL="http://caldav:80",
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
)
def test_signal_skips_calendar_when_not_entitled():
"""Calendar should NOT be created when entitlements deny access."""
get_entitlements_backend.cache_clear()
with (
mock.patch(
"core.signals.get_user_entitlements",
return_value={"can_access": False},
) as mock_ent,
mock.patch("core.signals.CalendarService") as mock_svc,
):
factories.UserFactory()
mock_ent.assert_called_once()
mock_svc.assert_not_called()
get_entitlements_backend.cache_clear()
@override_settings(
CALDAV_URL="http://caldav:80",
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
)
def test_signal_skips_calendar_when_entitlements_unavailable():
"""Calendar should NOT be created when entitlements are unavailable (fail-closed)."""
get_entitlements_backend.cache_clear()
with (
mock.patch(
"core.signals.get_user_entitlements",
side_effect=EntitlementsUnavailableError("unavailable"),
),
mock.patch("core.signals.CalendarService") as mock_svc,
):
factories.UserFactory()
mock_svc.assert_not_called()
get_entitlements_backend.cache_clear()
@override_settings(
CALDAV_URL="http://caldav:80",
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
ENTITLEMENTS_BACKEND_PARAMETERS={},
)
def test_signal_creates_calendar_when_entitled():
"""Calendar should be created when entitlements grant access."""
get_entitlements_backend.cache_clear()
with (
mock.patch(
"core.signals.get_user_entitlements",
return_value={"can_access": True},
),
mock.patch("core.signals.CalendarService") as mock_svc,
):
factories.UserFactory()
mock_svc.return_value.create_default_calendar.assert_called_once()
get_entitlements_backend.cache_clear()
# -- CalDAV proxy entitlements enforcement --
@pytest.mark.django_db
class TestCalDAVProxyEntitlements: # pylint: disable=no-member
"""Tests for entitlements enforcement in the CalDAV proxy."""
@responses.activate
def test_mkcalendar_blocked_when_not_entitled(self):
"""MKCALENDAR should return 403 when user has can_access=False."""
user = factories.UserFactory(email="test@example.com")
client = APIClient()
client.force_login(user)
with mock.patch(
"core.api.viewsets_caldav.get_user_entitlements",
return_value={"can_access": False},
):
response = client.generic(
"MKCALENDAR",
"/api/v1.0/caldav/calendars/test@example.com/new-cal/",
)
assert response.status_code == HTTP_403_FORBIDDEN
@responses.activate
def test_mkcol_blocked_when_not_entitled(self):
"""MKCOL should return 403 when user has can_access=False."""
user = factories.UserFactory(email="test@example.com")
client = APIClient()
client.force_login(user)
with mock.patch(
"core.api.viewsets_caldav.get_user_entitlements",
return_value={"can_access": False},
):
response = client.generic(
"MKCOL",
"/api/v1.0/caldav/calendars/test@example.com/new-cal/",
)
assert response.status_code == HTTP_403_FORBIDDEN
@responses.activate
def test_mkcalendar_blocked_when_entitlements_unavailable(self):
"""MKCALENDAR should return 403 when entitlements service
is unavailable (fail-closed)."""
user = factories.UserFactory(email="test@example.com")
client = APIClient()
client.force_login(user)
with mock.patch(
"core.api.viewsets_caldav.get_user_entitlements",
side_effect=EntitlementsUnavailableError("unavailable"),
):
response = client.generic(
"MKCALENDAR",
"/api/v1.0/caldav/calendars/test@example.com/new-cal/",
)
assert response.status_code == HTTP_403_FORBIDDEN
@responses.activate
def test_mkcalendar_allowed_when_entitled(self):
"""MKCALENDAR should be forwarded when user has can_access=True."""
user = factories.UserFactory(email="test@example.com")
client = APIClient()
client.force_login(user)
caldav_url = settings.CALDAV_URL
responses.add(
responses.Response(
method="MKCALENDAR",
url=f"{caldav_url}/api/v1.0/caldav/calendars/test@example.com/new-cal/",
status=201,
body="",
)
)
with mock.patch(
"core.api.viewsets_caldav.get_user_entitlements",
return_value={"can_access": True},
):
response = client.generic(
"MKCALENDAR",
"/api/v1.0/caldav/calendars/test@example.com/new-cal/",
)
assert response.status_code == 201
assert len(responses.calls) == 1
@responses.activate
def test_propfind_allowed_when_not_entitled(self):
"""PROPFIND should work for non-entitled users (shared calendars)."""
user = factories.UserFactory(email="test@example.com")
client = APIClient()
client.force_login(user)
caldav_url = settings.CALDAV_URL
responses.add(
responses.Response(
method="PROPFIND",
url=f"{caldav_url}/api/v1.0/caldav/",
status=HTTP_207_MULTI_STATUS,
body='<?xml version="1.0"?><multistatus xmlns="DAV:"></multistatus>',
headers={"Content-Type": "application/xml"},
)
)
# No entitlements mock needed — PROPFIND should not check entitlements
response = client.generic("PROPFIND", "/api/v1.0/caldav/")
assert response.status_code == HTTP_207_MULTI_STATUS
@responses.activate
def test_report_allowed_when_not_entitled(self):
"""REPORT should work for non-entitled users (shared calendars)."""
user = factories.UserFactory(email="test@example.com")
client = APIClient()
client.force_login(user)
caldav_url = settings.CALDAV_URL
responses.add(
responses.Response(
method="REPORT",
url=f"{caldav_url}/api/v1.0/caldav/calendars/other@example.com/cal-id/",
status=HTTP_207_MULTI_STATUS,
body='<?xml version="1.0"?><multistatus xmlns="DAV:"></multistatus>',
headers={"Content-Type": "application/xml"},
)
)
response = client.generic(
"REPORT",
"/api/v1.0/caldav/calendars/other@example.com/cal-id/",
)
assert response.status_code == HTTP_207_MULTI_STATUS
@responses.activate
def test_put_allowed_when_not_entitled(self):
"""PUT (event creation/update in shared calendar) should work
for non-entitled users."""
user = factories.UserFactory(email="test@example.com")
client = APIClient()
client.force_login(user)
caldav_url = settings.CALDAV_URL
responses.add(
responses.Response(
method="PUT",
url=f"{caldav_url}/api/v1.0/caldav/calendars/other@example.com/cal-id/event.ics",
status=HTTP_200_OK,
body="",
)
)
response = client.generic(
"PUT",
"/api/v1.0/caldav/calendars/other@example.com/cal-id/event.ics",
data=b"BEGIN:VCALENDAR\nEND:VCALENDAR",
content_type="text/calendar",
)
assert response.status_code == HTTP_200_OK
# -- import_events entitlements enforcement --
@pytest.mark.django_db
class TestImportEventsEntitlements: # pylint: disable=no-member
"""Tests for entitlements enforcement on import_events endpoint."""
def test_import_events_blocked_when_not_entitled(self):
"""import_events should return 403 when user has
can_access=False."""
user = factories.UserFactory(email="test@example.com")
client = APIClient()
client.force_login(user)
with mock.patch(
"core.api.permissions.get_user_entitlements",
return_value={"can_access": False},
):
response = client.post(
"/api/v1.0/calendars/import-events/",
data={},
format="multipart",
)
assert response.status_code == HTTP_403_FORBIDDEN
def test_import_events_blocked_when_entitlements_unavailable(self):
"""import_events should return 403 when entitlements service
is unavailable (fail-closed)."""
user = factories.UserFactory(email="test@example.com")
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/calendars/import-events/",
data={},
format="multipart",
)
assert response.status_code == HTTP_403_FORBIDDEN
def test_import_events_allowed_when_entitled(self):
"""import_events should proceed normally when user has
can_access=True (will fail on validation, not entitlements)."""
user = factories.UserFactory(email="test@example.com")
client = APIClient()
client.force_login(user)
with mock.patch(
"core.api.permissions.get_user_entitlements",
return_value={"can_access": True},
):
# No file or caldav_path — should fail with 400, not 403
response = client.post(
"/api/v1.0/calendars/import-events/",
data={},
format="multipart",
)
# Should pass entitlements check but fail on missing caldav_path
assert response.status_code == 400

View File

@@ -9,4 +9,5 @@ export interface User {
id: string; id: string;
email: string; email: string;
language: string; language: string;
can_access: boolean;
} }

View File

@@ -4,21 +4,6 @@
import { fetchAPI, fetchAPIFormData } from "@/features/api/fetchApi"; import { fetchAPI, fetchAPIFormData } from "@/features/api/fetchApi";
/**
* Create a new calendar via Django API (CalDAV only).
*/
export const createCalendarApi = async (data: {
name: string;
color?: string;
description?: string;
}): Promise<{ caldav_path: string }> => {
const response = await fetchAPI("calendars/", {
method: "POST",
body: JSON.stringify(data),
});
return response.json();
};
/** /**
* Subscription token for iCal export. * Subscription token for iCal export.
*/ */

View File

@@ -17,7 +17,6 @@ import type {
CalDavCalendarCreate, CalDavCalendarCreate,
} from "../services/dav/types/caldav-service"; } from "../services/dav/types/caldav-service";
import type { CalendarApi } from "../components/scheduler/types"; import type { CalendarApi } from "../components/scheduler/types";
import { createCalendarApi } from "../api";
import { import {
addToast, addToast,
ToasterItem, ToasterItem,
@@ -191,15 +190,15 @@ export const CalendarContextProvider = ({
params: CalDavCalendarCreate, params: CalDavCalendarCreate,
): Promise<{ success: boolean; error?: string }> => { ): Promise<{ success: boolean; error?: string }> => {
try { try {
// Use Django API to create calendar (CalDAV only) const result = await caldavService.createCalendar(params);
await createCalendarApi({ if (result.success) {
name: params.displayName, await refreshCalendars();
color: params.color, return { success: true };
description: params.description, }
}); return {
// Refresh CalDAV calendars list to show the new calendar success: false,
await refreshCalendars(); error: result.error || "Failed to create calendar",
return { success: true }; };
} catch (error) { } catch (error) {
console.error("Error creating calendar:", error); console.error("Error creating calendar:", error);
return { return {
@@ -208,7 +207,7 @@ export const CalendarContextProvider = ({
}; };
} }
}, },
[refreshCalendars], [caldavService, refreshCalendars],
); );
const updateCalendar = useCallback( const updateCalendar = useCallback(

View File

@@ -9,6 +9,11 @@
"title": "You don't have the necessary permissions to access this page.", "title": "You don't have the necessary permissions to access this page.",
"button": "Home" "button": "Home"
}, },
"no_access": {
"title": "Your login was successful, but this application is not available for your account.",
"description": "If you think this is a mistake, please contact support.",
"button": "Logout"
},
"common": { "common": {
"cancel": "Cancel" "cancel": "Cancel"
}, },
@@ -445,6 +450,11 @@
"title": "Vous n'avez pas les permissions nécessaires pour accéder à cette page.", "title": "Vous n'avez pas les permissions nécessaires pour accéder à cette page.",
"button": "Accueil" "button": "Accueil"
}, },
"no_access": {
"title": "Votre connexion a réussi, mais cette application n'est pas disponible pour votre compte.",
"description": "Si vous pensez qu'il s'agit d'une erreur, veuillez contacter le support.",
"button": "Se déconnecter"
},
"file_download_modal": { "file_download_modal": {
"error": { "error": {
"no_url_or_title": "Ce fichier n'a pas d'URL ou de titre. " "no_url_or_title": "Ce fichier n'a pas d'URL ou de titre. "

View File

@@ -28,6 +28,14 @@ export default function CalendarPage() {
return <SpinnerPage />; return <SpinnerPage />;
} }
// Redirect to no-access if not entitled
if (user.can_access === false) {
if (typeof window !== "undefined") {
window.location.href = "/no-access";
}
return <SpinnerPage />;
}
return ( return (
<> <>
<Head> <Head>

View File

@@ -27,6 +27,10 @@ export default function HomePage() {
useEffect(() => { useEffect(() => {
if (user) { if (user) {
if (user.can_access === false) {
window.location.href = "/no-access";
return;
}
const attemptedUrl = sessionStorage.getItem( const attemptedUrl = sessionStorage.getItem(
SESSION_STORAGE_REDIRECT_AFTER_LOGIN_URL SESSION_STORAGE_REDIRECT_AFTER_LOGIN_URL
); );
@@ -52,14 +56,6 @@ export default function HomePage() {
</ToasterItem> </ToasterItem>
); );
} }
if (failure === "user_cannot_access_app") {
addToast(
<ToasterItem type="error">
<span className="material-icons">lock</span>
<span>{t("authentication.error.user_cannot_access_app")}</span>
</ToasterItem>
);
}
}, []); }, []);
if (user) { if (user) {

View File

@@ -0,0 +1,41 @@
import { MainLayout } from "@gouvfr-lasuite/ui-kit";
import { Button } from "@gouvfr-lasuite/cunningham-react";
import { useTranslation } from "react-i18next";
import { logout } from "@/features/auth/Auth";
import { GlobalLayout } from "@/features/layouts/components/global/GlobalLayout";
import { HeaderRight } from "@/features/layouts/components/header/Header";
import { GenericDisclaimer } from "@/features/ui/components/generic-disclaimer/GenericDisclaimer";
import { DynamicCalendarLogo } from "@/features/ui/components/logo";
export default function NoAccessPage() {
const { t } = useTranslation();
return (
<GenericDisclaimer
message={t("no_access.title")}
imageSrc="/assets/403-background.png"
>
<p>{t("no_access.description")}</p>
<Button onClick={() => logout()}>{t("no_access.button")}</Button>
</GenericDisclaimer>
);
}
NoAccessPage.getLayout = function getLayout(page: React.ReactElement) {
return (
<GlobalLayout>
<MainLayout
enableResize={false}
hideLeftPanelOnDesktop={true}
icon={
<div className="calendars__header__left">
<DynamicCalendarLogo variant="header" />
</div>
}
rightHeaderContent={<HeaderRight />}
>
{page}
</MainLayout>
</GlobalLayout>
);
};