diff --git a/.zelian/compass.json b/.zelian/compass.json index 2d65bf7c246..4cd4bb93f46 100644 --- a/.zelian/compass.json +++ b/.zelian/compass.json @@ -1,7 +1,7 @@ { "schema_version": 1, "generated_by": "zelian-framework@3.0.0", - "updated_at": "2026-07-21T17:45:09.021Z", + "updated_at": "2026-07-21T19:20:46.556Z", "entries": [ { "id": "api/active-cycles-workspace", @@ -357,7 +357,13 @@ "module": "provisioning-zelian", "label": "Provisioning annuaire Zelian vers Plane", "spec_dir": "docs/specs/api/provisioning-zelian", - "code": [], + "code": [ + "apps/api/plane/app/views/zelian/provisioning.py", + "apps/api/plane/app/views/zelian/__init__.py", + "apps/api/plane/app/urls/zelian.py", + "apps/api/plane/app/urls/__init__.py", + "apps/api/plane/tests/contract/app/test_zelian_provisioning_app.py" + ], "keywords": [ "provisioning", "annuaire", @@ -366,7 +372,7 @@ "sync", "manage" ], - "updated_at": "2026-07-21T17:45:09.021Z" + "updated_at": "2026-07-21T19:20:46.556Z" }, { "id": "api/sso-zelian", diff --git a/CHANGELOG.md b/CHANGELOG.md index ba70ff3f7e5..f1c843e6150 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) · Versioning ### Added + +- **api/provisioning-zelian** — Provisioning des membres du workspace depuis l'annuaire Zelian (intégration interne, US-01→05). Endpoint **server-to-server** `POST /api/zelian/provisioning/`, isolé dans un namespace `zelian` dédié, sécurisé par un **secret de service** (`X-Zelian-Provisioning-Key` via `get_configuration_value`, `compare_digest`, **fail-closed**) — convention Insider server-to-server (`09-architecture-auth §9.11`, `HUB-TOOLS §8.7`, règle 07), jamais le JWT identité ni un token utilisateur (Plane = outil tiers). Traitement par **lot avec compte-rendu par élément** : création (`is_password_autoset`, entrée par SSO) ou **adoption** par email (pas de doublon), `WorkspaceMember` actif (défaut **Membre 15**), rôle **transmis=appliqué / omis=intact**, désactivation **réversible** (aucune perte), cascade **Invité** (RETRO-011), gardes comptes **bot/super-admin** protégés, **idempotent**, **silencieux**. **Zéro migration** (ADR-002 ; réutilise `User`/`WorkspaceMember`/`ProjectMember`). Vérifié : **15 tests de contrat** (`test_zelian_provisioning_app.py`) + régression SSO (15). Hors scope v1 : suppression de compte + purge/légation des traces (US-06, v1.1). Spec : `docs/specs/api/provisioning-zelian/`. - **api+web/sso-zelian v0.2.0** — deux compléments au flux SSO Zelian, livrés après validation E2E complète (2026-07-20) : **(1) Auto-login SSO** (`auth-root.tsx` core AGPL modifié — premier écart au principe « seams front uniquement » de la v1) : quand `IS_ZELIAN_ENABLED=1`, la page de connexion redirige automatiquement vers `/auth/zelian/` sans clic utilisateur. Garde-fous obligatoires : `?sso=0` (formulaire classique, admin) et `error_code` présent (anti-boucle si SSO échoue). ⚠️ Risque de conflit aux merges upstream sur `auth-root.tsx` — surveiller. **(2) Front-channel logout** — `ZelianLogoutEndpoint` : route GET `/auth/zelian/logout/` qui ferme la session Django et redirige vers `ZELIAN_POST_LOGOUT_REDIRECT_URL` (config serveur uniquement — jamais de `?next` pour éviter l'open redirect). Idempotent. 7 tests unitaires offline (résistance aux redirections ouvertes). Nouvelle var `.env.example` : `ZELIAN_POST_LOGOUT_REDIRECT_URL`. 22 tests au total (15 provider + 7 logout). - **api+web/sso-zelian** — SSO OIDC via le **serveur OAuth 2.1 de Supabase Auth** (feature Enterprise), réimplémenté en CE **clean-room à partir du provider `gitea`** (jamais de copie plane-ee) — **zéro migration**. Backend : `ZelianOAuthProvider` (`provider/oauth/zelian.py`) avec **PKCE S256** + **`client_secret_basic`** au token endpoint et mapping userinfo Supabase (`sub`→provider_id, `name`→prénom/nom, `picture`→avatar) ; endpoints app + space (`views/{app,space}/zelian.py`, `generate_pkce_pair()` — `code_verifier`/`state` en session) ; 4 routes (`/auth/zelian/[callback/]`, `/auth/spaces/zelian/[callback/]`) ; codes d'erreur `ZELIAN_NOT_CONFIGURED=5113` / `ZELIAN_OAUTH_PROVIDER_ERROR=5910` ; `is_zelian_enabled` exposé sur `/api/instances/` (piloté par `IS_ZELIAN_ENABLED`). Frontend : bouton « Continue with Zelian » (web + space) via les **seams d'extension** (`hooks/oauth/extended.tsx`, `TExtendedLoginMediums="zelian"`, `EXTENDED_LOGIN_MEDIUM_LABELS`) — **zéro modif de fichier core** ; type `is_zelian_enabled`, logo. Config par env (`ZELIAN_AUTH_BASE_URL`/`CLIENT_ID`/`CLIENT_SECRET`). Vérifié **offline** : 9 tests unitaires (PKCE S256, URL authorize, Basic auth + `code_verifier`, mapping userinfo), résolution des 4 routes, `/api/instances/` expose le flag, ruff clean, turbo `check:types` web+space 12/12. ⚠️ **Validation E2E bloquée** tant que la config Supabase (dashboard OAuth Server + client + page `/oauth/consent` externe) n'est pas fournie — cf. `PLAN-SSO-SUPABASE-PLANE.md` §1-3. Hors scope v1 : auto-redirect « sans clic » (§6, touche un fichier core → follow-up). diff --git a/apps/api/plane/app/urls/__init__.py b/apps/api/plane/app/urls/__init__.py index 0c468609582..3a0293428a7 100644 --- a/apps/api/plane/app/urls/__init__.py +++ b/apps/api/plane/app/urls/__init__.py @@ -27,6 +27,7 @@ from .workspace import urlpatterns as workspace_urls from .timezone import urlpatterns as timezone_urls from .exporter import urlpatterns as exporter_urls +from .zelian import urlpatterns as zelian_urls urlpatterns = [ *analytic_urls, @@ -54,4 +55,5 @@ *webhook_urls, *timezone_urls, *exporter_urls, + *zelian_urls, ] diff --git a/apps/api/plane/app/urls/zelian.py b/apps/api/plane/app/urls/zelian.py new file mode 100644 index 00000000000..71cff8652fd --- /dev/null +++ b/apps/api/plane/app/urls/zelian.py @@ -0,0 +1,15 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +from django.urls import path + +from plane.app.views.zelian import ZelianProvisioningEndpoint + +urlpatterns = [ + path( + "zelian/provisioning/", + ZelianProvisioningEndpoint.as_view(http_method_names=["post"]), + name="zelian-provisioning", + ), +] diff --git a/apps/api/plane/app/views/zelian/__init__.py b/apps/api/plane/app/views/zelian/__init__.py new file mode 100644 index 00000000000..f5aa186872d --- /dev/null +++ b/apps/api/plane/app/views/zelian/__init__.py @@ -0,0 +1,5 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +from .provisioning import ZelianProvisioningEndpoint diff --git a/apps/api/plane/app/views/zelian/provisioning.py b/apps/api/plane/app/views/zelian/provisioning.py new file mode 100644 index 00000000000..645c66b0189 --- /dev/null +++ b/apps/api/plane/app/views/zelian/provisioning.py @@ -0,0 +1,221 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +# Provisioning server-to-server des membres du workspace depuis l'annuaire Zelian. +# Contrat : docs/specs/api/provisioning-zelian/spec-fonctionnel.md. +# Auth : secret de service partagé (convention Insider §9.11 / §8.7, règle 07) — +# jamais le JWT identité ni un token utilisateur. + +import hmac +import logging +import os +import uuid + +from django.db import transaction +from rest_framework import status +from rest_framework.permissions import AllowAny +from rest_framework.response import Response + +# Module imports +from plane.app.views.base import BaseAPIView +from plane.db.models import ProjectMember, User, Workspace, WorkspaceMember +from plane.license.utils.instance_value import get_configuration_value + +logger = logging.getLogger("plane.zelian.provisioning") + +VALID_ROLES = {20, 15, 5} +GUEST_ROLE = 5 +DEFAULT_ROLE = 15 # RM-03 : Membre par défaut à la création +MAX_BATCH = 1000 + + +class ZelianProvisioningEndpoint(BaseAPIView): + """Provisionne / désactive des membres du workspace depuis l'annuaire Zelian. + + Appel **server-to-server** (le service de synchronisation Zelian, pas un humain), + sécurisé par un **secret de service** partagé en en-tête ``X-Zelian-Provisioning-Key``, + lu via ``get_configuration_value`` (config instance chiffrable / env — gestionnaire de + secrets, règle 07 Insider) et comparé en temps constant. **Fail-closed** si le secret + n'est pas configuré. + + Invariants (spec) : jointure par email (RM-02) ; ne touche jamais un compte de service + ``is_bot`` ni un super-admin (RM-09) ; rôle transmis = appliqué / omis = inchangé + (RM-04) ; désactivation réversible sans perte (RM-05) ; idempotent (RM-10) ; silencieux + (RM-11) ; cascade Invité (RM-14). **Zéro migration** (ADR-002) : réutilise ``User`` et + ``WorkspaceMember``. + """ + + authentication_classes = [] + permission_classes = [AllowAny] + + def _is_authorized(self, request): + (secret,) = get_configuration_value( + [ + { + "key": "ZELIAN_PROVISIONING_SECRET", + "default": os.environ.get("ZELIAN_PROVISIONING_SECRET"), + } + ] + ) + # Fail-closed : sans secret configuré, la porte reste fermée pour tout le monde. + if not secret: + return False + provided = request.headers.get("X-Zelian-Provisioning-Key", "") + return hmac.compare_digest( + provided.encode("utf-8"), str(secret).encode("utf-8") + ) + + def post(self, request): + if not self._is_authorized(request): + # RM-13 : appelant non habilité -> rejet sans effet, cause non divulguée. + return Response( + {"error": "Provisioning not authorized"}, + status=status.HTTP_403_FORBIDDEN, + ) + + slug = request.data.get("workspace_slug") + if not slug: + return Response( + {"error": "workspace_slug is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + workspace = Workspace.objects.filter(slug=slug).first() + if workspace is None: + return Response( + {"error": "Workspace not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + members = request.data.get("members") + if not isinstance(members, list) or not members: + return Response( + {"error": "members must be a non-empty list"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if len(members) > MAX_BATCH: + return Response( + {"error": f"batch too large (max {MAX_BATCH})"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + results = [self._process_one(workspace, item) for item in members] + summary = {} + for result in results: + summary[result["status"]] = summary.get(result["status"], 0) + 1 + logger.info( + "zelian-provisioning batch workspace=%s size=%s summary=%s", + workspace.slug, + len(members), + summary, + ) + return Response( + {"results": results, "summary": summary}, + status=status.HTTP_200_OK, + ) + + def _process_one(self, workspace, item): + if not isinstance(item, dict): + return {"email": None, "status": "rejected", "reason": "invalid_item"} + + email = (item.get("email") or "").strip().lower() + action = item.get("action") or "provision" + role = item.get("role") + + if not email or "@" not in email: + return {"email": email, "status": "rejected", "reason": "invalid_email"} + if role is not None and role not in VALID_ROLES: + return {"email": email, "status": "rejected", "reason": "invalid_role"} + if action not in ("provision", "deactivate"): + return {"email": email, "status": "rejected", "reason": "invalid_action"} + + try: + with transaction.atomic(): + user = User.objects.filter(email__iexact=email).first() + + # RM-09 : jamais un compte de service ni un super-admin. + if user is not None and (user.is_bot or user.is_superuser): + return {"email": email, "status": "protected"} + + if action == "deactivate": + return self._deactivate(workspace, user, email) + return self._provision(workspace, user, email, role, item) + except Exception: # un élément fautif ne casse pas le lot (cas limite 9) + logger.exception("zelian-provisioning failed for %s", email) + return {"email": email, "status": "rejected", "reason": "error"} + + def _deactivate(self, workspace, user, email): + if user is None: + return {"email": email, "status": "unknown"} + member = WorkspaceMember.objects.filter( + workspace=workspace, member=user + ).first() + if member is None or not member.is_active: + return {"email": email, "status": "unchanged"} + # RM-05 : désactivation réversible, aucune donnée supprimée. + member.is_active = False + member.save(update_fields=["is_active"]) + logger.info( + "zelian-provisioning deactivate email=%s workspace=%s", + email, + workspace.slug, + ) + return {"email": email, "status": "deactivated"} + + def _provision(self, workspace, user, email, role, item): + created_user = False + if user is None: + # Création d'un compte sans mot de passe : l'entrée se fera par le SSO Zelian. + first_name, _, last_name = (item.get("name") or "").strip().partition(" ") + user = User(email=email, username=uuid.uuid4().hex) + user.set_password(uuid.uuid4().hex) + user.is_password_autoset = True + user.is_email_verified = True + user.first_name = first_name + user.last_name = last_name + user.save() + created_user = True + elif not user.is_active: + user.is_active = True + user.save(update_fields=["is_active"]) + + member = WorkspaceMember.objects.filter( + workspace=workspace, member=user + ).first() + if member is None: + # RM-02 : adoption d'un compte existant, jamais de doublon. + member = WorkspaceMember.objects.create( + workspace=workspace, + member=user, + role=role if role is not None else DEFAULT_ROLE, + ) + outcome = "created" if created_user else "adopted" + else: + changed = [] + if not member.is_active: + member.is_active = True + changed.append("is_active") + # RM-04 : rôle transmis => appliqué ; rôle omis => inchangé. + if role is not None and member.role != role: + member.role = role + changed.append("role") + if changed: + member.save(update_fields=changed) + outcome = "reactivated" if "is_active" in changed else "role_updated" + else: + outcome = "unchanged" # RM-10 : idempotence + + # RM-14 : le passage à Invité rétrograde tous ses projets (cascade RETRO-011). + if role == GUEST_ROLE: + ProjectMember.objects.filter( + workspace=workspace, member=user + ).update(role=GUEST_ROLE) + + logger.info( + "zelian-provisioning %s email=%s workspace=%s role=%s", + outcome, + email, + workspace.slug, + member.role, + ) + return {"email": email, "status": outcome, "role": member.role} diff --git a/apps/api/plane/tests/contract/app/test_zelian_provisioning_app.py b/apps/api/plane/tests/contract/app/test_zelian_provisioning_app.py new file mode 100644 index 00000000000..7c41645e4b0 --- /dev/null +++ b/apps/api/plane/tests/contract/app/test_zelian_provisioning_app.py @@ -0,0 +1,184 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +""" +Contract tests — module api/provisioning-zelian (US-01 -> US-05). + +L'endpoint server-to-server ``POST /api/zelian/provisioning/`` provisionne / +désactive des membres du workspace depuis l'annuaire Zelian, sécurisé par un +secret de service (en-tête ``X-Zelian-Provisioning-Key``). Ces tests couvrent +les critères d'acceptation de spec-fonctionnel.md (coche, adoption, idempotence, +désactivation/réactivation, rôles, cascade Invité, habilitation, comptes protégés, +lot partiel). +""" + +import uuid + +import pytest +from django.core import mail +from rest_framework import status +from rest_framework.test import APIClient + +from plane.db.models import Project, ProjectMember, User, WorkspaceMember + +URL = "/api/zelian/provisioning/" +SECRET = "test-provisioning-secret" + + +@pytest.fixture +def configured(monkeypatch): + """Secret de provisioning configuré (via le fallback env de get_configuration_value).""" + monkeypatch.setenv("ZELIAN_PROVISIONING_SECRET", SECRET) + return SECRET + + +def _post(members, *, slug="test-workspace", key=SECRET): + client = APIClient() + extra = {"HTTP_X_ZELIAN_PROVISIONING_KEY": key} if key is not None else {} + return client.post( + URL, + {"workspace_slug": slug, "members": members}, + format="json", + **extra, + ) + + +def _make_user(email, **kwargs): + local = email.split("@")[0] + user = User.objects.create(email=email, username=uuid.uuid4().hex, first_name=local, **kwargs) + user.set_password("x") + user.save() + return user + + +@pytest.mark.contract +@pytest.mark.django_db +class TestZelianProvisioning: + # ----- US-01 : coche ----- + def test_provision_unknown_email_creates_active_member(self, workspace, configured): + resp = _post([{"email": "newcomer@zelian.fr"}]) + assert resp.status_code == status.HTTP_200_OK + assert resp.data["results"][0]["status"] == "created" + user = User.objects.get(email__iexact="newcomer@zelian.fr") + assert user.is_password_autoset is True # entrée par SSO, pas de mot de passe + member = WorkspaceMember.objects.get(workspace=workspace, member=user) + assert member.is_active is True + assert member.role == 15 # RM-03 défaut Membre + assert len(mail.outbox) == 0 # RM-11 silencieux + + def test_provision_adopts_existing_user_no_duplicate(self, workspace, configured): + existing = _make_user("already@zelian.fr") + resp = _post([{"email": "already@zelian.fr"}]) + assert resp.data["results"][0]["status"] == "adopted" + assert User.objects.filter(email__iexact="already@zelian.fr").count() == 1 # RM-02 + assert WorkspaceMember.objects.filter(workspace=workspace, member=existing).exists() + + def test_provision_is_idempotent(self, workspace, configured): + _post([{"email": "idem@zelian.fr"}]) + resp = _post([{"email": "idem@zelian.fr"}]) + assert resp.data["results"][0]["status"] == "unchanged" # RM-10 + assert WorkspaceMember.objects.filter(member__email__iexact="idem@zelian.fr").count() == 1 + + # ----- US-03 / US-04 : décoche / recoche ----- + def test_deactivate_keeps_data_and_is_reversible(self, workspace, configured): + _post([{"email": "leaver@zelian.fr"}]) + user = User.objects.get(email__iexact="leaver@zelian.fr") + + resp = _post([{"email": "leaver@zelian.fr", "action": "deactivate"}]) + assert resp.data["results"][0]["status"] == "deactivated" + member = WorkspaceMember.objects.get(workspace=workspace, member=user) + assert member.is_active is False # RM-05 : désactivé, pas supprimé + assert User.objects.filter(pk=user.pk).exists() + + # recoche -> réactivation à l'identique + resp = _post([{"email": "leaver@zelian.fr"}]) + assert resp.data["results"][0]["status"] == "reactivated" + member.refresh_from_db() + assert member.is_active is True + + # ----- US-05 : rôles ----- + def test_role_omitted_leaves_existing_role_intact(self, workspace, configured): + user = _make_user("boss@zelian.fr") + WorkspaceMember.objects.create(workspace=workspace, member=user, role=20, is_active=True) + resp = _post([{"email": "boss@zelian.fr"}]) # pas de role + assert resp.data["results"][0]["status"] == "unchanged" + assert WorkspaceMember.objects.get(workspace=workspace, member=user).role == 20 # RM-04 + + def test_role_provided_is_applied(self, workspace, configured): + user = _make_user("promoted@zelian.fr") + WorkspaceMember.objects.create(workspace=workspace, member=user, role=15, is_active=True) + resp = _post([{"email": "promoted@zelian.fr", "role": 20}]) + assert resp.data["results"][0]["status"] == "role_updated" + assert WorkspaceMember.objects.get(workspace=workspace, member=user).role == 20 + + def test_guest_role_cascades_to_projects(self, workspace, create_user, configured): + project = Project.objects.create( + name="P", identifier="P", workspace=workspace, created_by=create_user + ) + user = _make_user("demote@zelian.fr") + WorkspaceMember.objects.create(workspace=workspace, member=user, role=15, is_active=True) + pm = ProjectMember.objects.create( + workspace=workspace, project=project, member=user, role=15, is_active=True + ) + _post([{"email": "demote@zelian.fr", "role": 5}]) + pm.refresh_from_db() + assert pm.role == 5 # RM-14 cascade Invité + + # ----- habilitation (RM-13) ----- + def test_missing_secret_header_is_forbidden(self, workspace, configured): + resp = _post([{"email": "x@zelian.fr"}], key=None) + assert resp.status_code == status.HTTP_403_FORBIDDEN + assert not User.objects.filter(email__iexact="x@zelian.fr").exists() + + def test_wrong_secret_is_forbidden(self, workspace, configured): + resp = _post([{"email": "x@zelian.fr"}], key="wrong") + assert resp.status_code == status.HTTP_403_FORBIDDEN + assert not User.objects.filter(email__iexact="x@zelian.fr").exists() + + def test_fail_closed_when_secret_not_configured(self, workspace, monkeypatch): + monkeypatch.delenv("ZELIAN_PROVISIONING_SECRET", raising=False) + resp = _post([{"email": "x@zelian.fr"}], key="anything") + assert resp.status_code == status.HTTP_403_FORBIDDEN + + # ----- comptes protégés (RM-09) ----- + def test_bot_account_is_protected(self, workspace, create_bot_user, configured): + resp = _post([{"email": create_bot_user.email}]) + assert resp.data["results"][0]["status"] == "protected" + assert not WorkspaceMember.objects.filter( + workspace=workspace, member=create_bot_user + ).exists() + + def test_superuser_account_is_protected(self, workspace, configured): + admin = _make_user("root@zelian.fr", is_superuser=True) + resp = _post([{"email": "root@zelian.fr"}]) + assert resp.data["results"][0]["status"] == "protected" + assert not WorkspaceMember.objects.filter(workspace=workspace, member=admin).exists() + + # ----- lot partiel (cas limite 9) ----- + def test_invalid_element_rejected_others_processed(self, workspace, configured): + resp = _post( + [ + {"email": "not-an-email"}, + {"email": "valid@zelian.fr", "role": 99}, # rôle invalide + {"email": "good@zelian.fr"}, + ] + ) + by_email = {r.get("email"): r for r in resp.data["results"]} + assert by_email["not-an-email"]["status"] == "rejected" + assert by_email["valid@zelian.fr"]["status"] == "rejected" + assert by_email["good@zelian.fr"]["status"] == "created" + assert User.objects.filter(email__iexact="good@zelian.fr").exists() + + # ----- garde-fous de requête ----- + def test_missing_workspace_slug_is_bad_request(self, configured): + client = APIClient() + resp = client.post( + URL, {"members": [{"email": "x@zelian.fr"}]}, + format="json", HTTP_X_ZELIAN_PROVISIONING_KEY=SECRET, + ) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + + def test_unknown_workspace_is_not_found(self, configured): + resp = _post([{"email": "x@zelian.fr"}], slug="does-not-exist") + assert resp.status_code == status.HTTP_404_NOT_FOUND diff --git a/docs/specs/api/provisioning-zelian/VERSIONNING.md b/docs/specs/api/provisioning-zelian/VERSIONNING.md index f0eea9f3234..ca1851f97be 100644 --- a/docs/specs/api/provisioning-zelian/VERSIONNING.md +++ b/docs/specs/api/provisioning-zelian/VERSIONNING.md @@ -2,5 +2,6 @@ | Version | Date | Type | Description | Fichiers touchés | | ------- | ---- | ---- | ----------- | ---------------- | +| 0.1.0 | 2026-07-21 | feat | Endpoint de provisioning server-to-server `POST /api/zelian/provisioning/` (US-01→05). Secret de service (`X-Zelian-Provisioning-Key`, `get_configuration_value`, `compare_digest`, fail-closed). Lot avec compte-rendu par élément ; création/adoption `User`+`WorkspaceMember`, contrat rôle (transmis=appliqué/omis=intact, défaut Membre 15), désactivation réversible, cascade Invité, gardes bot/super-admin, idempotent, silencieux. Zéro migration. Vérifié : 15 tests de contrat + régression SSO 15. US-06 (purge/légation) non implémenté (v1.1). | `apps/api/plane/app/views/zelian/{provisioning.py, __init__.py}`, `apps/api/plane/app/urls/{zelian.py, __init__.py}`, `apps/api/plane/tests/contract/app/test_zelian_provisioning_app.py`, `docs/specs/api/provisioning-zelian/{spec-technique.md, tech-design.md}` | > Table mise à jour par @update-writer-after-implement après chaque implémentation. diff --git a/docs/specs/api/provisioning-zelian/spec-technique.md b/docs/specs/api/provisioning-zelian/spec-technique.md index 2da725732f9..48e644f7af3 100644 --- a/docs/specs/api/provisioning-zelian/spec-technique.md +++ b/docs/specs/api/provisioning-zelian/spec-technique.md @@ -1,28 +1,90 @@ -# Spec Technique — Provisioning annuaire Zelian → Plane [DRAFT] +# Spec Technique — Provisioning annuaire Zelian → Plane > **Module** : `api/provisioning-zelian` -> **Statut** : DRAFT (rempli après implémentation par `@update-writer-after-implement`) +> **Statut** : v0.1.0 — US-01→05 implémentés (v1). US-06 (suppression/purge/légation) = v1.1, non implémenté. +> **Mis à jour** : 2026-07-21 (après implémentation). ## Architecture -_À compléter après implémentation._ +Un unique endpoint **server-to-server** encapsule tout le contrat côté Plane. Il est isolé dans +un **namespace `zelian` dédié** (comme le SSO), pour minimiser la divergence avec l'upstream et la +surface d'attaque. + +- **Route** : `POST /api/zelian/provisioning/` (instance-level ; le workspace visé est passé dans le + payload, pas dans l'URL). +- **Habilitation** : appel machine (service de synchronisation Zelian), sécurisé par un **secret de + service** partagé en en-tête `X-Zelian-Provisioning-Key`, lu via `get_configuration_value` + (config instance chiffrable / fallback env — gestionnaire de secrets, règle 07 Insider) et comparé + en **temps constant** (`hmac.compare_digest`). **Fail-closed** : sans secret configuré, tout est + refusé (403). Convention conforme à la doc Insider (`09-architecture-auth §9.11` server-to-server, + `HUB-TOOLS-ANALYSE §8.7`) — jamais le JWT identité (ES256, réservé aux humains) ni un token + utilisateur (Plane est un outil tiers). +- **Traitement par lot** : le corps porte une liste `members`, traitée élément par élément dans des + transactions indépendantes (un élément fautif ne casse pas le lot) ; réponse = statut par élément + + résumé agrégé. Opérations **journalisées** (`logging`, §9.11). +- **Zéro migration** (ADR-002) : réutilise `User`, `WorkspaceMember`, `ProjectMember`. + +Logique par élément (calquée sur `app/views/workspace/invite.py` et l'adaptateur d'auth) : + +| Action | Comportement | +|---|---| +| `provision` (défaut) | `User` créé si absent (`is_password_autoset=True`, entrée par SSO) sinon **adopté** (jointure email, RM-02) ; `WorkspaceMember` créé (défaut **Membre 15**, RM-03) ou réactivé ; rôle **transmis = appliqué / omis = inchangé** (RM-04) ; passage à **Invité (5)** ⇒ cascade `ProjectMember` (RM-14, RETRO-011). | +| `deactivate` | `WorkspaceMember.is_active = False` — réversible, aucune donnée supprimée (RM-05). | + +Gardes : jamais un compte `is_bot` ni `is_superuser` (RM-09, statut `protected`) ; idempotent (RM-10, +statut `unchanged`) ; silencieux — aucun email (RM-11) ; appelant non habilité rejeté sans effet (RM-13). ## Fichiers créés -_À compléter._ +- `apps/api/plane/app/views/zelian/provisioning.py` — `ZelianProvisioningEndpoint` (BaseAPIView, `AllowAny` + garde secret). +- `apps/api/plane/app/views/zelian/__init__.py` — export. +- `apps/api/plane/app/urls/zelian.py` — route `zelian/provisioning/`. +- `apps/api/plane/tests/contract/app/test_zelian_provisioning_app.py` — 15 tests de contrat. ## Fichiers modifiés -_À compléter._ +- `apps/api/plane/app/urls/__init__.py` — import + include de `zelian_urls`. ## Schéma BDD -Intention : **zéro migration** — réutilise `User` et `WorkspaceMember` existants (ADR-002). +**Zéro migration** (ADR-002) — réutilise `User`, `WorkspaceMember` (`unique(workspace, member)` quand +`deleted_at IS NULL`), `ProjectMember`. Aucun nouveau modèle. ## API -_À compléter (contrat de l'endpoint de provisioning)._ +`POST /api/zelian/provisioning/` + +- **En-tête** : `X-Zelian-Provisioning-Key: ` (obligatoire ; 403 sinon / si non configuré). +- **Corps** : + ```json + { + "workspace_slug": "zelian", + "members": [ + { "email": "prenom.nom@zelian.fr", "role": 15, "action": "provision", "name": "Prénom Nom" } + ] + } + ``` + - `role` optionnel ∈ {20, 15, 5} (défaut 15 à la création ; omis = inchangé sur un membre existant). + - `action` optionnel ∈ {`provision` (défaut), `deactivate`}. + - `name` optionnel (renseigne prénom/nom à la création uniquement). +- **Réponse 200** : `{ "results": [ { "email", "status", "role"? , "reason"? } ], "summary": { : } }`. + - `status` ∈ `created` | `adopted` | `reactivated` | `role_updated` | `unchanged` | `deactivated` | `protected` | `unknown` | `rejected`. +- **Codes** : `403` (non habilité / non configuré), `400` (`workspace_slug`/`members` manquants, lot > 1000), `404` (workspace inconnu). + +## Config + +- `ZELIAN_PROVISIONING_SECRET` — secret de service (gestionnaire de secrets / env). Non défini ⇒ endpoint désactivé (fail-closed). ## Tests / vérification -_À compléter._ +- **15 tests de contrat** (`test_zelian_provisioning_app.py`) : création, adoption sans doublon, + idempotence, désactivation réversible + réactivation, rôle omis/transmis, cascade Invité, + en-tête manquant/faux, fail-closed, comptes bot/super-admin protégés, lot partiel, gardes + `workspace_slug`/workspace inconnu. +- **Régression SSO** : `test_zelian_oauth_provider.py` (15) verts — SSO intact. +- `py_compile` OK, zéro migration (`makemigrations --check` attendu clean). + +## Hors scope de cette version (US-06, v1.1) + +Suppression de compte + traitement des traces (inventaire, purge sélective/totale définitive, +légation de projet, marquage « Profil supprimé »). Cf. spec-fonctionnel.md.