Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions docs/entitlements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# Entitlements System

The entitlements system provides a pluggable backend architecture for checking user access rights and synchronizing mail domain admin permissions. It integrates with the DeployCenter (Espace Operateur) API in production and uses a local backend for development.

## Architecture

```text
┌─────────────────────────────────────────────┐
│ OIDC Authentication Backend │
│ _sync_entitlements() on every login │
└──────────────┬──────────────────────────────┘
┌──────────────▼──────────────────────────────┐
│ 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, returns `None` for `can_admin_maildomains` (disabling sync).
- **DeployCenter backend** (`core/entitlements/backends/deploycenter.py`): Calls the DeployCenter API with internal Django cache.
- **OIDC sync** (`core/authentication/backends.py`): Syncs `MailDomainAccess` ADMIN records on every login based on `can_admin_maildomains`.

### Error Handling

- **Login sync is fail-open**: if the entitlements service is unavailable during OIDC login, existing `MailDomainAccess` records are preserved and the user is allowed in.
- 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 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/api/v1.0/entitlements/",
"service_id": "42",
"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` header |
| `timeout` | No | HTTP timeout in seconds (default: 10) |
| `oidc_claims` | No | List of OIDC claim names to extract from user_info and forward as query params (e.g. `["siret"]`) |

### 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":"42","api_key":"secret-key","timeout":10,"oidc_claims":["siret"]}'
ENTITLEMENTS_CACHE_TIMEOUT=300
Comment thread
sylvinus marked this conversation as resolved.
```

## 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, user_info=None, force_refresh=False):
# Return: {"can_access": bool, "can_admin_maildomains": list[str] | None}
# Return None for can_admin_maildomains to skip domain admin sync.
# Raise EntitlementsUnavailableError on failure.
pass
```

## DeployCenter API

The DeployCenter backend calls:

```text
GET {base_url}?service_id=X&account_type=user&account_email=X&siret=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`).

Response: `{"entitlements": {"can_access": bool, "can_admin_maildomains": [str], ...}}`

## OIDC Login Integration

During OIDC login (`post_get_or_create_user`), the system:

1. Calls `get_user_entitlements` with `force_refresh=True` (resets cache)
2. Syncs `MailDomainAccess` ADMIN records based on `can_admin_maildomains`:
- Compares entitled domains with existing records (optimistic early return if in sync)
- Creates missing admin accesses for entitled domains (using `update_or_create`)
- Removes admin accesses for domains not in the entitled list
3. If `can_admin_maildomains` is `None` (e.g. local backend), sync is skipped entirely
4. If the entitlements service is unavailable, existing accesses are preserved (fail-open)

### Caching Behavior

- The DeployCenter backend caches entitlements in Django's cache framework (TTL: `ENTITLEMENTS_CACHE_TIMEOUT`).
- On login, `force_refresh=True` bypasses the cache to fetch fresh data.
- If the API fails during a forced refresh, stale cached data is returned as fallback.
- Logging out and back in triggers a fresh fetch, effectively resetting the cache for that user.

### Deployment Consideration

Before enabling the DeployCenter backend in production, ensure that existing domain admin assignments are present in DeployCenter. The entitlements sync will **remove** admin accesses that are not in the DeployCenter response.
10 changes: 10 additions & 0 deletions src/backend/core/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,14 @@
"description": "Whether external images should be proxied",
"readOnly": true
},
"FEATURE_MAILDOMAIN_CREATE": {
"type": "boolean",
"readOnly": true
},
"FEATURE_MAILDOMAIN_MANAGE_ACCESSES": {
"type": "boolean",
"readOnly": true
},
"MESSAGES_MANUAL_RETRY_MAX_AGE": {
"type": "integer",
"description": "Maximum age in seconds for a message to be eligible for manual retry of failed deliveries",
Expand All @@ -267,6 +275,8 @@
"MAX_RECIPIENTS_PER_MESSAGE",
"MAX_TEMPLATE_IMAGE_SIZE",
"IMAGE_PROXY_ENABLED",
"FEATURE_MAILDOMAIN_CREATE",
"FEATURE_MAILDOMAIN_MANAGE_ACCESSES",
"MESSAGES_MANUAL_RETRY_MAX_AGE"
]
}
Expand Down
19 changes: 19 additions & 0 deletions src/backend/core/api/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@
}


class DenyAll(permissions.BasePermission):
"""Always denies access. Used to disable endpoints via feature flags."""

def has_permission(self, request, view):
return False


class IsAuthenticated(permissions.BasePermission):
"""
Allows access only to authenticated users. Alternative method checking the presence
Expand Down Expand Up @@ -423,6 +430,18 @@ def has_permission(self, request, view):
)


class HasProvisioningApiKey(permissions.BasePermission):
"""Allows access only to requests bearing the provisioning API key."""

def has_permission(self, request, view):
if not settings.PROVISIONING_API_KEY:
return False
return compare_digest(
request.headers.get("Authorization") or "",
f"Bearer {settings.PROVISIONING_API_KEY}",
)


class HasAccessToMailbox(IsAuthenticated):
"""Allows access only to users with the access to the mailbox."""

Expand Down
31 changes: 31 additions & 0 deletions src/backend/core/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1759,3 +1759,34 @@ def create(self, validated_data):

def update(self, instance, validated_data):
"""This serializer is only used to validate the data, not to create or update."""


class DomainsField(serializers.Field):
"""Accepts either a JSON list of strings or a comma-separated string."""

def to_internal_value(self, data):
if isinstance(data, str):
data = [d.strip() for d in data.split(",") if d.strip()]
if not isinstance(data, list):
raise serializers.ValidationError(
"Expected a list of domains or a comma-separated string."
)
if not data:
raise serializers.ValidationError("At least one domain is required.")
return data

def to_representation(self, value):
return value


class ProvisioningMailDomainSerializer(serializers.Serializer):
"""Serializer for the provisioning endpoint that creates mail domains."""

domains = DomainsField()
custom_attributes = serializers.JSONField(required=False, default=dict)

def create(self, validated_data):
"""This serializer is only used to validate the data, not to create or update."""

def update(self, instance, validated_data):
"""This serializer is only used to validate the data, not to create or update."""
12 changes: 12 additions & 0 deletions src/backend/core/api/viewsets/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,14 @@ class ConfigView(drf.views.APIView):
"description": "Whether external images should be proxied",
"readOnly": True,
},
"FEATURE_MAILDOMAIN_CREATE": {
"type": "boolean",
"readOnly": True,
},
"FEATURE_MAILDOMAIN_MANAGE_ACCESSES": {
"type": "boolean",
"readOnly": True,
},
"MESSAGES_MANUAL_RETRY_MAX_AGE": {
"type": "integer",
"description": (
Expand All @@ -136,6 +144,8 @@ class ConfigView(drf.views.APIView):
"MAX_RECIPIENTS_PER_MESSAGE",
"MAX_TEMPLATE_IMAGE_SIZE",
"IMAGE_PROXY_ENABLED",
"FEATURE_MAILDOMAIN_CREATE",
"FEATURE_MAILDOMAIN_MANAGE_ACCESSES",
"MESSAGES_MANUAL_RETRY_MAX_AGE",
],
},
Expand All @@ -158,6 +168,8 @@ def get(self, request):
"IMAGE_PROXY_ENABLED",
"MESSAGES_MANUAL_RETRY_MAX_AGE",
"FEATURE_MAILBOX_ADMIN_CHANNELS",
"FEATURE_MAILDOMAIN_CREATE",
"FEATURE_MAILDOMAIN_MANAGE_ACCESSES",
"MAX_OUTGOING_ATTACHMENT_SIZE",
"MAX_OUTGOING_BODY_SIZE",
"MAX_INCOMING_EMAIL_SIZE",
Expand Down
2 changes: 2 additions & 0 deletions src/backend/core/api/viewsets/maildomain.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ class AdminMailDomainViewSet(

def get_permissions(self):
if self.action == "create":
if not settings.FEATURE_MAILDOMAIN_CREATE:
return [core_permissions.DenyAll()]
return [core_permissions.IsSuperUser()]
return super().get_permissions()

Expand Down
7 changes: 7 additions & 0 deletions src/backend/core/api/viewsets/maildomain_access.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""API ViewSet for MaildomainAccess model."""

from django.conf import settings
from django.shortcuts import get_object_or_404

from drf_spectacular.utils import extend_schema
Expand Down Expand Up @@ -31,6 +32,12 @@ class MaildomainAccessViewSet(
lookup_field = "pk"
pagination_class = None

def get_permissions(self):
if self.action in ("create", "destroy"):
if not settings.FEATURE_MAILDOMAIN_MANAGE_ACCESSES:
return [core_permissions.DenyAll()]
return super().get_permissions()

def get_serializer_class(self):
"""Select serializer based on action."""
if self.action in ["create", "update", "partial_update"]:
Expand Down
74 changes: 74 additions & 0 deletions src/backend/core/api/viewsets/provisioning.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""API view for provisioning mail domains from DeployCenter."""

import logging

from django.core.exceptions import ValidationError
from django.db import IntegrityError

from drf_spectacular.utils import extend_schema
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
from sentry_sdk import capture_exception

from core.api.permissions import HasProvisioningApiKey
from core.api.serializers import ProvisioningMailDomainSerializer
from core.models import MailDomain

logger = logging.getLogger(__name__)


class ProvisioningMailDomainView(APIView):
"""Provision mail domains from DeployCenter webhooks."""

permission_classes = [HasProvisioningApiKey]
authentication_classes = []

@extend_schema(exclude=True)
def post(self, request):
"""Provision mail domains from a list of domain names."""
serializer = ProvisioningMailDomainSerializer(data=request.data)
serializer.is_valid(raise_exception=True)

domains = serializer.validated_data["domains"]
custom_attributes = serializer.validated_data.get("custom_attributes", {})

created = []
existing = []
errors = []

for domain_name in domains:
try:
domain, was_created = MailDomain.objects.get_or_create(
name=domain_name,
defaults={"custom_attributes": custom_attributes},
)
if was_created:
created.append(domain_name)
else:
if domain.custom_attributes != custom_attributes:
domain.custom_attributes = custom_attributes
domain.save()
existing.append(domain_name)
except ValidationError as e:
errors.append({"domain": domain_name, "error": str(e)})
except IntegrityError as exc:
capture_exception(exc)
logger.exception(
"IntegrityError while provisioning domain %s", domain_name
)
errors.append(
{
"domain": domain_name,
"error": "Failed to provision domain.",
}
)

return Response(
{
"created": created,
"existing": existing,
"errors": errors,
},
Comment on lines +68 to +72

Check warning

Code scanning / CodeQL

Information exposure through an exception Medium

Stack trace information
flows to this location and may be exposed to an external user.

Copilot Autofix

AI 3 months ago

To fix the problem, avoid returning the raw exception message to the client. Instead, either provide a generic error description or a very controlled, minimal message that does not expose stack traces, internal field names, or database details. Any detailed information from the exception should be logged server-side (using logger or Sentry) rather than sent in the HTTP response.

The best minimal-change fix here is to:

  • Catch ValidationError as before, but:
    • Optionally log the exception (e.g., logger.warning(...)).
    • Append a safe, generic error message to errors for the client, such as “Invalid domain.” or “Invalid domain name.” instead of str(e).

This preserves existing behavior in terms of which domains are recorded as errors and how the overall response is structured (created, existing, errors), while no longer exposing the raw exception text. All changes are in src/backend/core/api/viewsets/provisioning.py, within the except ValidationError as e: block. No new imports are strictly required, since logger is already defined; we can just add a logging call if desired.

Suggested changeset 1
src/backend/core/api/viewsets/provisioning.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/backend/core/api/viewsets/provisioning.py b/src/backend/core/api/viewsets/provisioning.py
--- a/src/backend/core/api/viewsets/provisioning.py
+++ b/src/backend/core/api/viewsets/provisioning.py
@@ -51,7 +51,17 @@
                         domain.save()
                     existing.append(domain_name)
             except ValidationError as e:
-                errors.append({"domain": domain_name, "error": str(e)})
+                logger.warning(
+                    "ValidationError while provisioning domain %s: %s",
+                    domain_name,
+                    e,
+                )
+                errors.append(
+                    {
+                        "domain": domain_name,
+                        "error": "Invalid domain.",
+                    }
+                )
             except IntegrityError as exc:
                 capture_exception(exc)
                 logger.exception(
EOF
@@ -51,7 +51,17 @@
domain.save()
existing.append(domain_name)
except ValidationError as e:
errors.append({"domain": domain_name, "error": str(e)})
logger.warning(
"ValidationError while provisioning domain %s: %s",
domain_name,
e,
)
errors.append(
{
"domain": domain_name,
"error": "Invalid domain.",
}
)
except IntegrityError as exc:
capture_exception(exc)
logger.exception(
Copilot is powered by AI and may make mistakes. Always verify output.
status=status.HTTP_200_OK,
)
Loading