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
12 changes: 9 additions & 3 deletions .zelian/compass.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 2 additions & 0 deletions apps/api/plane/app/urls/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -54,4 +55,5 @@
*webhook_urls,
*timezone_urls,
*exporter_urls,
*zelian_urls,
]
15 changes: 15 additions & 0 deletions apps/api/plane/app/urls/zelian.py
Original file line number Diff line number Diff line change
@@ -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",
),
]
5 changes: 5 additions & 0 deletions apps/api/plane/app/views/zelian/__init__.py
Original file line number Diff line number Diff line change
@@ -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
221 changes: 221 additions & 0 deletions apps/api/plane/app/views/zelian/provisioning.py
Original file line number Diff line number Diff line change
@@ -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}
Loading
Loading