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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) · Versioning

### Added

- **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).
- **api+web/views-access** — Vues privées/publiques (feature Pro) fonctionnelles en CE — **zéro migration** : le modèle (`IssueView.access` 0=Private/1=Public, défaut Public), le queryset de visibilité (`Q(owned_by)|Q(access=1)`, listes + détail, projet + workspace) et les gardes owner-only existaient déjà ; le POST/PATCH droppait simplement `access` (read_only). Backend : `access` retiré des `read_only_fields` de `IssueViewSerializer` (partagé par les 2 viewsets) — `is_locked` reste non inscriptible ; valeurs hors {0,1} → 400 (ChoiceField dérivé du modèle). Web : stubs CE remplis — sélecteur Public/Private du formulaire de vue (`access-controller.tsx`, réutilise `AccessField` + `VIEW_ACCESS_SPECIFIERS` upstream) et facette de filtre « Access » (`filters/access-filter.tsx`). **Revue adversariale passée** — 3 fuites de vue privée fermées (surfaces devenues significatives dès que `access` est inscriptible) : VA-01 une vue publiée puis passée en privé restait servie via l'anchor public (`space` `AllowAny`) → publication d'une vue privée refusée (400) + dépublication automatique (DeployBoard supprimé) au passage en privé ; VA-02 la recherche globale (`filter_views`) fuitait nom/id des vues privées d'autrui → filtre `Q(access=1)|Q(owned_by=user)` ; VA-03 on pouvait mettre en favori la vue privée d'un autre → vérification d'accès avant création du favori (404 sinon). Deux correctifs UI de la même revue : COR-va-1 le retrait du chip « Private » (valeur 0, falsy) effaçait tout le filtre `view_type` (garde `value === null` au lieu de `!value`) ; COR-va-2 les libellés Private/Public de la facette de filtre passés en clés i18n (`common.access.*` présentes dans les 19 locales) au lieu de littéraux anglais. Vérifié : **16 tests pytest Docker** (persistance access=0, toggle owner, refus non-owner 400/403, access invalide 400, vue privée cachée aux autres / visible au owner — projet ET workspace, is_locked non inscriptible, non-publication + dépublication auto, recherche + favoris scopés), E2E navigateur (création « Vue privee test » → `access:0` persisté, facette rendue), `makemigrations --check` clean, tsc EXIT=0. Hors scope : tier « Shared » (table de partage inexistante), verrouillage de vue, layouts additionnels workspace.
- **api+web/milestones** — Milestones / jalons projet (feature payante) alignés sur le **SDK/MCP officiel** (source de vérité : plane-mcp-server 0.2.19). Modèles greenfield `Milestone(ProjectBaseModel)` (`name`, `description`, `target_date`, `external_source/id`, `sort_order`) table `milestones` et `MilestoneIssue` (FK issue+milestone, `UniqueConstraint(issue, milestone) WHERE deleted_at IS NULL`, calque `CycleIssue`) table `milestone_issues` + toggle `Project.is_milestone_enabled` (défaut False) — **migration `0128_milestones`** (dép 0127). **Couche v1 token/MCP** : CRUD `/api/v1/.../milestones/[{id}/]` + `GET/POST/DELETE .../work-items/` — **`title` exposé via `source="name"`** (le SDK Pydantic l'exige), listes cursor-paginées enveloppe `BasePaginator`, POST work-items `{issues:[uuid]}` AJOUTE (validation UUID + même-projet 400, déjà-liés ignorés), **DELETE avec body** RETIRE (204), dedup import externe 409, écritures gatées 400 si toggle off, scoping ProjectMember. **Couche app interne** pour le web (mêmes règles, retrait unitaire `/milestone-issues/:issue_id/`, rejet explicite des ids étrangers — parité v1). **Web** : type `TMilestone`, `milestone.service.ts`, store MobX scopé projet, page `/:ws/projects/:pid/milestones` + entrée nav gatée, toggle « Milestones » (Project Settings → Features), section liste (modal create/edit RHF, delete + confirmation, attach/detach via `ExistingIssuesListModal`, compteurs X/Y). i18n : 6 clés plates + bloc `disabled_project.empty_state.milestone` × **19 locales** (`sync:check` OK). **Revue adversariale passée** (2 audits sécurité + correction) : sécurité solide (isolation cross-projet/workspace, permissions, gate vérifiés) — 1 durcissement SEC-ms-1 (`MilestoneWriteSerializer` app : `created_by`/`updated_by`/`deleted_at`/`sort_order` passés en `read_only_fields` contre le mass-assignment via PATCH) ; corrections COR-ms-1 (`is_milestone_enabled` ajouté à la projection `.values()` de la liste projets → l'entrée nav sidebar apparaît sans ouvrir le projet), COR-ms-2 (work items du jalon exposés inline via `IssueProjectLiteSerializer` → nom + identifiant `PROJ-N` au lieu de l'UUID brut), COR-ms-3 (liens fetchés à l'ouverture du modal d'attache → items déjà attachés cachés), COR-ms-4 (en-tête de modal + tooltips i18n ×19), COR-ms-5 (lien sidebar ouvert aux guests, parité backend/page). Vérifié : **19 tests pytest Docker** (contrat v1 title/enveloppe/409/gate/work-items ; app CRUD/guest/scoping/compteurs/expansion issue_detail/anti-mass-assignment), `makemigrations --check` clean, turbo `check:types` 11/11, **chaîne MCP validée EN DIRECT sur l'instance locale** (`create_milestone` → `title` ✓, `manage_milestone_work_items` ✓, `list_milestone_work_items` ✓, `update_milestone` ✓, `list_milestones` avec compteurs ✓), UI vérifiée navigateur (page, nav gatée, jalon déplié → work item « DEMO-1 Aider Anthony »).
Expand Down
7 changes: 7 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,10 @@ IS_ZELIAN_ENABLED="0"
ZELIAN_AUTH_BASE_URL="" # e.g. https://<ref>.supabase.co/auth/v1
ZELIAN_CLIENT_ID=""
ZELIAN_CLIENT_SECRET=""
# Where /auth/zelian/logout/ sends the user after closing the Plane session.
# Point it at the shared Zelian sign-in page so a global sign-out lands there
# (e.g. http://localhost:3102/login in dev, https://auth.zelian.fr/login in prod).
# Server-side only, never read from the request: a caller-supplied return URL
# would turn the logout endpoint into an open redirect.
# Left empty, the user is sent back to the Plane home page.
ZELIAN_POST_LOGOUT_REDIRECT_URL=""
4 changes: 4 additions & 0 deletions apps/api/plane/authentication/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
GiteaCallbackSpaceEndpoint,
GiteaOauthInitiateSpaceEndpoint,
ZelianCallbackEndpoint,
ZelianLogoutEndpoint,
ZelianOauthInitiateEndpoint,
ZelianCallbackSpaceEndpoint,
ZelianOauthInitiateSpaceEndpoint,
Expand Down Expand Up @@ -157,6 +158,9 @@
## Zelian (Supabase) Oauth
path("zelian/", ZelianOauthInitiateEndpoint.as_view(), name="zelian-initiate"),
path("zelian/callback/", ZelianCallbackEndpoint.as_view(), name="zelian-callback"),
# Front-channel logout : la mire Zelian y envoie l'utilisateur pour fermer
# la session Plane en meme temps que celle de l'ecosysteme (GET, cf. docstring).
path("zelian/logout/", ZelianLogoutEndpoint.as_view(), name="zelian-logout"),
path(
"spaces/zelian/",
ZelianOauthInitiateSpaceEndpoint.as_view(),
Expand Down
2 changes: 1 addition & 1 deletion apps/api/plane/authentication/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from .app.github import GitHubCallbackEndpoint, GitHubOauthInitiateEndpoint
from .app.gitlab import GitLabCallbackEndpoint, GitLabOauthInitiateEndpoint
from .app.gitea import GiteaCallbackEndpoint, GiteaOauthInitiateEndpoint
from .app.zelian import ZelianCallbackEndpoint, ZelianOauthInitiateEndpoint
from .app.zelian import ZelianCallbackEndpoint, ZelianLogoutEndpoint, ZelianOauthInitiateEndpoint
from .app.google import GoogleCallbackEndpoint, GoogleOauthInitiateEndpoint
from .app.magic import MagicGenerateEndpoint, MagicSignInEndpoint, MagicSignUpEndpoint

Expand Down
12 changes: 9 additions & 3 deletions apps/api/plane/authentication/views/app/signout.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,18 @@
from django.utils import timezone

# Module imports
from plane.authentication.utils.host import user_ip, base_host
from plane.authentication.utils.host import user_ip
from plane.authentication.views.app.zelian import zelian_post_logout_url
from plane.db.models import User


class SignOutAuthEndpoint(View):
def post(self, request):
# Sous SSO Zelian, la destination est la mire — sinon la page de
# connexion de Plane relance l'auto-login et reconnecte aussitôt
# l'utilisateur, qui ne peut alors plus se déconnecter. Hors SSO, le
# helper retourne la racine de Plane : comportement inchangé.
redirect_url = zelian_post_logout_url(request)
# Get user
try:
user = User.objects.get(pk=request.user.id)
Expand All @@ -23,6 +29,6 @@ def post(self, request):
user.save()
# Log the user out
logout(request)
return HttpResponseRedirect(base_host(request=request, is_app=True))
return HttpResponseRedirect(redirect_url)
except Exception:
return HttpResponseRedirect(base_host(request=request, is_app=True))
return HttpResponseRedirect(redirect_url)
78 changes: 78 additions & 0 deletions apps/api/plane/authentication/views/app/zelian.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,23 @@

import base64
import hashlib
import os
import secrets
import uuid
from urllib.parse import urlencode, urljoin

# Django import
from django.contrib.auth import logout
from django.http import HttpResponseRedirect
from django.utils import timezone
from django.views import View

# Module imports
from plane.authentication.provider.oauth.zelian import ZelianOAuthProvider
from plane.authentication.utils.host import user_ip
from plane.authentication.utils.login import user_login
from plane.db.models import User
from plane.license.utils.instance_value import get_configuration_value
from plane.authentication.utils.redirection_path import get_redirection_path
from plane.authentication.utils.user_auth_workflow import post_user_auth_workflow
from plane.license.models import Instance
Expand Down Expand Up @@ -124,3 +130,75 @@ def get(self, request):
params["next_path"] = str(validate_next_path(next_path))
url = urljoin(base_host, "?" + urlencode(params))
return HttpResponseRedirect(url)


def zelian_post_logout_url(request):
"""Où envoyer l'utilisateur après avoir fermé sa session Plane.

Sous SSO Zelian, rester dans Plane enferme l'utilisateur : la page de
connexion relance aussitôt l'auto-login (`auth-root.tsx`), la session
Supabase est toujours valide, et le voilà reconnecté sans avoir pu sortir.
On le renvoie donc à la mire, seule habilitée à fermer la session de
l'écosystème.

Retourne la racine de Plane si le SSO est inactif ou la destination non
configurée — le comportement d'origine est alors préservé à l'identique.
"""
(IS_ZELIAN_ENABLED, ZELIAN_POST_LOGOUT_REDIRECT_URL) = get_configuration_value(
[
{
"key": "IS_ZELIAN_ENABLED",
"default": os.environ.get("IS_ZELIAN_ENABLED", "0"),
},
{
"key": "ZELIAN_POST_LOGOUT_REDIRECT_URL",
"default": os.environ.get("ZELIAN_POST_LOGOUT_REDIRECT_URL"),
},
]
)
if IS_ZELIAN_ENABLED == "1" and ZELIAN_POST_LOGOUT_REDIRECT_URL:
return ZELIAN_POST_LOGOUT_REDIRECT_URL
return base_host(request=request, is_app=True)


class ZelianLogoutEndpoint(View):
"""Déconnexion déclenchable depuis l'extérieur (front-channel logout OIDC).

Se déconnecter d'une app Zelian doit fermer la session partout, Plane
compris. Or Plane tient sa propre session Django, indépendante de celle de
Supabase : détruire l'une laisse l'autre intacte.

`/auth/sign-out/` ne convient pas ici — il n'accepte que POST, donc une
redirection depuis la mire ne le déclenche pas. Ce point d'entrée en GET
joue le rôle du `frontchannel_logout_uri` d'OIDC : la mire y envoie
l'utilisateur après avoir fermé sa propre session.

Sécurité — la destination est prise dans la configuration serveur
(`ZELIAN_POST_LOGOUT_REDIRECT_URL`), jamais dans la requête : un paramètre
de retour librement fourni ferait de cet endpoint une redirection ouverte.
À défaut de configuration, on retombe sur la racine de Plane.

Un endpoint de déconnexion en GET reste exposé au « logout CSRF » — un tiers
peut forcer la fermeture de session via une simple balise image. La
conséquence se limite à une déconnexion subie, sans accès ni perte de
données ; c'est le compromis retenu par le front-channel logout OIDC.
"""

def get(self, request):
redirect_url = zelian_post_logout_url(request)

# Session déjà fermée (ou jamais ouverte) : l'appel reste idempotent,
# la mire peut nous appeler sans connaître l'état côté Plane.
if not request.user.is_authenticated:
return HttpResponseRedirect(redirect_url)

try:
user = User.objects.get(pk=request.user.id)
user.last_logout_ip = user_ip(request=request)
user.last_logout_time = timezone.now()
user.save()
except User.DoesNotExist:
pass

logout(request)
return HttpResponseRedirect(redirect_url)
109 changes: 109 additions & 0 deletions apps/api/plane/tests/unit/test_zelian_logout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

"""Offline unit tests for the Zelian logout path.

Signing out of one Zelian app must close the session everywhere, Plane
included. Plane keeps its own Django session, independent from the Supabase
one, so two things are needed:

* `/auth/zelian/logout/` — a GET entry point the shared sign-in page can call
(the OIDC `frontchannel_logout_uri` role); `/auth/sign-out/` only accepts
POST and cannot be triggered by a redirect.
* `zelian_post_logout_url()` — sends the user back to the sign-in page instead
of Plane's home. Staying inside Plane would trap them: the sign-in page
re-triggers auto-login, the Supabase session is still valid, and they are
signed straight back in.

The critical property covered here: the redirect target comes from server
configuration only. Honouring a caller-supplied return URL would turn the
endpoint into an open redirect.
"""

from unittest.mock import patch

import pytest
from django.contrib.auth.models import AnonymousUser
from django.test import RequestFactory

from plane.authentication.views.app.zelian import (
ZelianLogoutEndpoint,
zelian_post_logout_url,
)

pytestmark = pytest.mark.django_db

MIRE = "http://localhost:3102/login"


def _request(query=""):
request = RequestFactory().get(f"/auth/zelian/logout/{query}")
request.user = AnonymousUser()
request.session = {}
return request


def _patch_config(enabled="1", url=MIRE):
"""`zelian_post_logout_url` reads IS_ZELIAN_ENABLED then the target URL."""
return patch(
"plane.authentication.views.app.zelian.get_configuration_value",
return_value=(enabled, url),
)


def _response(query="", enabled="1", url=MIRE):
with _patch_config(enabled, url):
return ZelianLogoutEndpoint().get(_request(query))


class TestPostLogoutUrl:
def test_uses_configured_url_under_sso(self):
with _patch_config():
assert zelian_post_logout_url(_request()) == MIRE

def test_falls_back_to_plane_home_when_sso_disabled(self):
"""Without SSO, the original behaviour must be preserved exactly."""
with _patch_config(enabled="0"):
assert zelian_post_logout_url(_request()) != MIRE

def test_falls_back_to_plane_home_when_target_unconfigured(self):
with _patch_config(url=None):
assert zelian_post_logout_url(_request()) != MIRE


class TestZelianLogoutRedirect:
def test_redirects_to_configured_url(self):
response = _response()
assert response.status_code == 302
assert response.url == MIRE

def test_ignores_caller_supplied_return_url(self):
"""An attacker-controlled `next` must never reach the Location header."""
response = _response("?next=https://attacker.example/steal")
assert response.url == MIRE
assert "attacker.example" not in response.url

@pytest.mark.parametrize(
"param",
[
"?redirect_uri=https://attacker.example",
"?post_logout_redirect_uri=https://attacker.example",
"?next=//attacker.example",
],
)
def test_ignores_every_redirect_parameter_shape(self, param):
assert _response(param).url == MIRE

def test_stays_in_plane_when_unconfigured(self):
"""Without configuration, stay inside Plane rather than guess a target."""
response = _response(url=None)
assert response.status_code == 302
assert "attacker" not in response.url
assert response.url.startswith("http")


class TestZelianLogoutIdempotence:
def test_anonymous_user_is_not_an_error(self):
"""The sign-in page calls us without knowing whether a session exists."""
assert _response().status_code == 302
Loading