-
Notifications
You must be signed in to change notification settings - Fork 25
✨(entitlements) add DeployCenter backend for syncing maildomain admins #572
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
81c9272
de5c3eb
8ec080a
605e08c
706368c
2c998bf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| ``` | ||
|
|
||
| ## 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. | ||
| 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 warningCode scanning / CodeQL Information exposure through an exception Medium Stack trace information Error loading related location Loading
Copilot AutofixAI 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 The best minimal-change fix here is to:
This preserves existing behavior in terms of which domains are recorded as errors and how the overall response is structured (
Suggested changeset
1
src/backend/core/api/viewsets/provisioning.py
Copilot is powered by AI and may make mistakes. Always verify output.
Refresh and try again.
|
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| status=status.HTTP_200_OK, | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.