Kanbante 0.46.6 — porte centrale unique : réglage administrateur, écran réduit à l'entrée SSO et porte de secours discrète
CI / test (pull_request) Failing after 2m20s
CI / test (push) Failing after 2m54s

This commit is contained in:
Spark
2026-11-07 04:19:08 +00:00
parent f8f8af63e1
commit e4531488d0
9 changed files with 504 additions and 16 deletions
+8
View File
@@ -6,6 +6,14 @@ GITEA_TOKEN=
KANBANTE_AUTH_USER= KANBANTE_AUTH_USER=
KANBANTE_AUTH_PASSWORD= KANBANTE_AUTH_PASSWORD=
KANBANTE_REQUIRE_AUTH=0 KANBANTE_REQUIRE_AUTH=0
# Porte centrale unique (désactivée par défaut ; activable aussi au
# panneau d'administration, réglage « porte_centrale »)
KANBANTE_PORTE_CENTRALE=0
# Porte de secours administrateur (chemin discret /login/secours,
# jamais proposé sur l'écran principal) — compte administrateur
# local désigné, identifiants d'environnement uniquement :
# KANBANTE_PORTE_SECOURS_USER=
# KANBANTE_PORTE_SECOURS_PASSWORD=
# Noms publics dracodev — défauts = noms OFFICIELS (prod). # Noms publics dracodev — défauts = noms OFFICIELS (prod).
# Environnement dev : déclarer ici les noms en -dev, sans toucher au code. # Environnement dev : déclarer ici les noms en -dev, sans toucher au code.
KANBANTE_PUBLIC_URL=https://kanbante.dracodev.net KANBANTE_PUBLIC_URL=https://kanbante.dracodev.net
+10
View File
@@ -1,3 +1,13 @@
## 0.46.6 — 2026-11-06 — Porte centrale unique (programme « compte partout », phase 2 — basée sur la 0.46.5)
Version **fonctionnelle** dans la série 0.46.x (base : branche 0.46.5, fusion #145/#144 ; la 0.47.0 de la PR #87 reste indépendante et non touchée). **Désactivée par défaut à la livraison** : sans activation explicite d'un administrateur, le comportement est exactement celui de la 0.46.5.
- **Nouveau réglage `porte_centrale`** (booléen, `app/config.py`) : amorçable par l'environnement (`KANBANTE_PORTE_CENTRALE=1`) et **surchargeable au panneau d'administration** (Administration → Paramètres, patron « l'environnement amorce, le panneau gouverne »). Un compte créé au Hub n'a pas de mot de passe local (il vit chez ObsiGate) : la porte centrale fait de la voie SSO le parcours normal au lieu d'un formulaire voué à échouer pour ces comptes.
- **Mode actif** : l'écran de connexion ne présente **que l'entrée centrale** — le lien « Se connecter avec ObsiGate (SSO) », bien visible, avec l'explication qu'il n'y a pas de mot de passe local ; aucun champ local n'est rendu, et `POST /login` refuse toute tentative (403), identifiants de base comme repli d'environnement. **Sessions déjà établies : aucun impact** (contrôle de session inchangé). Non touchés : Basic d'environnement (voie scripts), jetons `kbt_`, pont `/auth/draco`, `/api/search`.
- **Porte de secours administrateur** : un seul compte désigné, par l'environnement uniquement — `KANBANTE_PORTE_SECOURS_USER` / `KANBANTE_PORTE_SECOURS_PASSWORD` (secrets, jamais en base ni dans le panneau ni en dur) — joignable par le **chemin discret `/login/secours`**, jamais proposé ni lié sur l'écran principal ; sans les deux variables, le chemin répond 404. Gardes : le compte désigné doit être un administrateur local **actif** (ou l'opérateur d'environnement) — un membre désigné par erreur est refusé ; le geste est journalisé (`login.porte_secours`). Fiche complète : `docs/porte-centrale.md` (dont la fiche d'activation opérateur).
- **Correctif au passage (panneau des paramètres)** : la conversion des réglages booléens à l'enregistrement est désormais explicite (`true`/`false` lus en toutes lettres) — auparavant `bool("false")` valait `True`, donc un booléen du panneau (dont `porte_centrale`, mais aussi `sync_*`, `briefing_include_standup`) ne pouvait pas se désactiver en saisissant `false`.
- **Tests** : nouveau `tests/test_porte_centrale.py` (7) — mode inactif par défaut = comportement actuel ; activation puis désactivation par le panneau ; mode actif : écran central seul, secours jamais proposé, formulaire refusé pour un membre comme pour l'amorçage, session préexistante intacte ; porte de secours : chemin discret fonctionnel en plein mode central, mauvais mot de passe et membre refusés, membre désigné refusé, 404 sans configuration ; amorçage par variable d'environnement et surcharge du panneau par-dessus.
## 0.46.5 — 2026-11-06 — Pont draco : adoption de l'amorçage conditionnée (fusion #145) et échec de connexion qui suggère le SSO (fusion #144, part Kanbante) ## 0.46.5 — 2026-11-06 — Pont draco : adoption de l'amorçage conditionnée (fusion #145) et échec de connexion qui suggère le SSO (fusion #144, part Kanbante)
Version **corrective de sécurité** dans la série 0.46.x (base 0.46.4 ; la 0.47.0 de la PR #87, métadonnées des jetons, reste indépendante et non touchée). Version **corrective de sécurité** dans la série 0.46.x (base 0.46.4 ; la 0.47.0 de la PR #87, métadonnées des jetons, reste indépendante et non touchée).
+2
View File
@@ -120,6 +120,8 @@ sudo systemctl enable --now docker
| `KANBANTE_DB_PATH` | `~/.kanbante/kanbante.db` | Base SQLite locale (en Docker : `/data/kanbante.db`) | | `KANBANTE_DB_PATH` | `~/.kanbante/kanbante.db` | Base SQLite locale (en Docker : `/data/kanbante.db`) |
| `KANBANTE_AUTH_USER` / `KANBANTE_AUTH_PASSWORD` | *(vide)* | Active l'authentification dès que les deux sont définis : écran de connexion + session signée pour les navigateurs ; l'en-tête Basic reste accepté pour les scripts | | `KANBANTE_AUTH_USER` / `KANBANTE_AUTH_PASSWORD` | *(vide)* | Active l'authentification dès que les deux sont définis : écran de connexion + session signée pour les navigateurs ; l'en-tête Basic reste accepté pour les scripts |
| `KANBANTE_REQUIRE_AUTH` | `0` | `1` = l'app refuse de servir (503) si l'auth n'est pas configurée | | `KANBANTE_REQUIRE_AUTH` | `0` | `1` = l'app refuse de servir (503) si l'auth n'est pas configurée |
| `KANBANTE_PORTE_CENTRALE` | `0` | `1` = porte centrale unique : l'écran de connexion ne présente que l'entrée ObsiGate (SSO) et le formulaire local est refusé ; surchargeable au panneau d'administration (réglage `porte_centrale`) — voir `docs/porte-centrale.md` |
| `KANBANTE_PORTE_SECOURS_USER` / `KANBANTE_PORTE_SECOURS_PASSWORD` | *(vide)* | Porte de secours : compte administrateur local désigné, joignable par le chemin discret `/login/secours` (jamais proposé sur l'écran principal) ; vide = porte désactivée |
| `KANBANTE_SESSION_SECRET` | *(dérivé)* | Clé HMAC du cookie de session ; par défaut dérivée des identifiants (changer le mot de passe invalide les sessions) | | `KANBANTE_SESSION_SECRET` | *(dérivé)* | Clé HMAC du cookie de session ; par défaut dérivée des identifiants (changer le mot de passe invalide les sessions) |
| `KANBANTE_SNAPSHOT_INTERVAL` | `120` | Secondes entre les rafraîchissements d'instantanés d'arrière-plan (chantier B) ; `0` = désactivé | | `KANBANTE_SNAPSHOT_INTERVAL` | `120` | Secondes entre les rafraîchissements d'instantanés d'arrière-plan (chantier B) ; `0` = désactivé |
| `KANBANTE_ACTIVE_WINDOW_DAYS` | `365` | Un dépôt non archivé mis à jour depuis moins de N jours est « actif » | | `KANBANTE_ACTIVE_WINDOW_DAYS` | `365` | Un dépôt non archivé mis à jour depuis moins de N jours est « actif » |
+1 -1
View File
@@ -1 +1 @@
0.46.5 0.46.6
+29
View File
@@ -11,6 +11,24 @@ Environment variables:
KANBANTE_AUTH_USER Login username (with _PASSWORD: enables auth). KANBANTE_AUTH_USER Login username (with _PASSWORD: enables auth).
KANBANTE_AUTH_PASSWORD Login password. KANBANTE_AUTH_PASSWORD Login password.
KANBANTE_REQUIRE_AUTH "1" = refuse to serve if auth is not configured. KANBANTE_REQUIRE_AUTH "1" = refuse to serve if auth is not configured.
KANBANTE_PORTE_CENTRALE "1" = active la « porte centrale unique »
(0.46.6) : l'écran de connexion ne présente
plus que l'entrée centrale (SSO ObsiGate)
et le formulaire local est refusé. Défaut
"0" (désactivé) ; surchargeable depuis le
panneau d'administration (réglage
``porte_centrale``, l'environnement amorce,
le panneau gouverne).
KANBANTE_PORTE_SECOURS_USER / KANBANTE_PORTE_SECOURS_PASSWORD
Identifiants de la porte de secours
administrateur (0.46.6) : LE compte
administrateur local désigné, joignable
par le chemin discret /login/secours
(jamais proposé sur l'écran principal),
y compris quand la porte centrale est
active. Secrets d'environnement uniquement,
jamais en base ni dans le panneau. Variables
absentes = porte de secours désactivée.
KANBANTE_SESSION_SECRET HMAC key for the login session cookie KANBANTE_SESSION_SECRET HMAC key for the login session cookie
(default: derived from the credentials — (default: derived from the credentials —
rotating the password invalidates sessions). rotating the password invalidates sessions).
@@ -370,6 +388,9 @@ class Settings:
auth_user: str = "" auth_user: str = ""
auth_password: str = "" auth_password: str = ""
require_auth: bool = False require_auth: bool = False
porte_centrale: bool = False
porte_secours_user: str = ""
porte_secours_password: str = ""
active_window_days: int = 365 active_window_days: int = 365
cache_ttl: float = 30.0 cache_ttl: float = 30.0
max_note_chars: int = 20_000 max_note_chars: int = 20_000
@@ -456,6 +477,9 @@ def _env_settings() -> Settings:
auth_user=os.getenv("KANBANTE_AUTH_USER", ""), auth_user=os.getenv("KANBANTE_AUTH_USER", ""),
auth_password=os.getenv("KANBANTE_AUTH_PASSWORD", ""), auth_password=os.getenv("KANBANTE_AUTH_PASSWORD", ""),
require_auth=_env_bool("KANBANTE_REQUIRE_AUTH", False), require_auth=_env_bool("KANBANTE_REQUIRE_AUTH", False),
porte_centrale=_env_bool("KANBANTE_PORTE_CENTRALE", False),
porte_secours_user=os.getenv("KANBANTE_PORTE_SECOURS_USER", ""),
porte_secours_password=os.getenv("KANBANTE_PORTE_SECOURS_PASSWORD", ""),
active_window_days=_env_int("KANBANTE_ACTIVE_WINDOW_DAYS", 365), active_window_days=_env_int("KANBANTE_ACTIVE_WINDOW_DAYS", 365),
cache_ttl=_env_float("KANBANTE_CACHE_TTL", 30.0), cache_ttl=_env_float("KANBANTE_CACHE_TTL", 30.0),
max_note_chars=_env_int("KANBANTE_MAX_NOTE_CHARS", 20_000), max_note_chars=_env_int("KANBANTE_MAX_NOTE_CHARS", 20_000),
@@ -551,6 +575,11 @@ OVERRIDABLE_SETTINGS: dict[str, type] = {
"capture_repo": str, "capture_repo": str,
"sync_labels": bool, "sync_labels": bool,
"sync_gitea": bool, "sync_gitea": bool,
# Porte centrale unique (0.46.6) : booléen du panneau —
# l'administrateur l'active/la désactive sans redémarrage.
# Les identifiants de la porte de secours, eux, restent en
# environnement (secrets, jamais surchargeables).
"porte_centrale": bool,
} }
+133 -15
View File
@@ -249,7 +249,7 @@ registry = connections_service.ForgeRegistry()
SESSION_COOKIE = "kanbante_session" SESSION_COOKIE = "kanbante_session"
SESSION_DAYS = 7 SESSION_DAYS = 7
_PUBLIC_PATHS = ("/api/health", "/webhooks/gitea", "/api/capture", "/login", _PUBLIC_PATHS = ("/api/health", "/webhooks/gitea", "/api/capture", "/login",
"/auth/draco") "/login/secours", "/auth/draco")
def _session_secret() -> bytes: def _session_secret() -> bytes:
@@ -536,11 +536,30 @@ def _safe_next(raw: str) -> str:
return "/" return "/"
def _ouvrir_session(username: str, next: str) -> RedirectResponse:
"""Réponse de connexion native : session signée posée, redirection."""
response = RedirectResponse(_safe_next(next), status_code=303)
response.set_cookie(
SESSION_COOKIE,
_make_session(username),
max_age=SESSION_DAYS * 86400,
httponly=True,
samesite="lax",
)
return response
@app.get("/login") @app.get("/login")
async def login_page(request: Request, next: str = "/"): async def login_page(request: Request, next: str = "/"):
if not get_settings().auth_enabled or _session_user(request) is not None: if not get_settings().auth_enabled or _session_user(request) is not None:
return RedirectResponse(_safe_next(next), status_code=303) return RedirectResponse(_safe_next(next), status_code=303)
return render(request, "login.html", next=_safe_next(next), error="") # Porte centrale (0.46.6) : en mode actif, le gabarit ne
# présente que l'entrée centrale (lien SSO) — aucun champ
# local (voir app/templates/login.html et docs/porte-centrale.md).
return render(
request, "login.html", next=_safe_next(next), error="",
porte_centrale=bool(get_settings().porte_centrale),
)
@app.post("/login") @app.post("/login")
@@ -559,6 +578,25 @@ async def login_submit(
request, "login.html", status_code=403, request, "login.html", status_code=403,
next=_safe_next(next), error="Session expirée — réessaie.", next=_safe_next(next), error="Session expirée — réessaie.",
) )
if settings.porte_centrale:
# Porte centrale unique (0.46.6) : en mode actif, les
# identifiants locaux ordinaires ne sont plus acceptés
# sur ce formulaire — ni mot de passe de base, ni repli
# d'environnement. Seules restent : l'entrée centrale
# (SSO ObsiGate, ci-dessous sur l'écran) et, pour
# l'administrateur désigné, la porte de secours discrète
# (/login/secours, identifiants d'environnement — jamais
# proposée ici). Les sessions déjà établies ne passent
# pas par ce formulaire et ne sont pas touchées.
return render(
request, "login.html", status_code=403,
next=_safe_next(next), porte_centrale=True,
error=(
"La porte centrale est active sur cette instance : "
"la connexion locale est fermée. Utilise « Se "
"connecter avec ObsiGate (SSO) » ci-dessous."
),
)
from app import accounts as accounts_service from app import accounts as accounts_service
user = models.get_user_by_username(username) user = models.get_user_by_username(username)
@@ -622,15 +660,88 @@ async def login_submit(
sub = claims.get("sub") sub = claims.get("sub")
if sub and models.get_user_by_draco(sub) is None: if sub and models.get_user_by_draco(sub) is None:
models.update_user(user["id"], draco_sub=sub) models.update_user(user["id"], draco_sub=sub)
response = RedirectResponse(_safe_next(next), status_code=303) return _ouvrir_session(username, next)
response.set_cookie(
SESSION_COOKIE,
_make_session(username), # ── Porte de secours administrateur (0.46.6) ──────────────────
max_age=SESSION_DAYS * 86400, # Chemin discret /login/secours : jamais lié ni proposé sur
httponly=True, # l'écran principal (ni en mode porte centrale, ni sinon).
samesite="lax", # Un seul compte, désigné par l'environnement :
# KANBANTE_PORTE_SECOURS_USER / KANBANTE_PORTE_SECOURS_PASSWORD
# (voir app/config.py). Le compte désigné doit être un
# administrateur local actif (ou l'opérateur d'environnement) :
# un membre désigné par erreur ne passe pas. Sans les deux
# variables, la porte n'existe pas (404 sec, sans explication).
def _porte_secours_configuree(settings) -> bool:
return bool(settings.porte_secours_user and settings.porte_secours_password)
@app.get("/login/secours")
async def login_secours_page(request: Request, next: str = "/"):
settings = get_settings()
if not settings.auth_enabled or _session_user(request) is not None:
return RedirectResponse(_safe_next(next), status_code=303)
if not _porte_secours_configuree(settings):
return render(
request, "error.html", status_code=404,
message="Page introuvable.",
)
return render(
request, "login.html", next=_safe_next(next), error="",
mode_secours=True,
) )
return response
@app.post("/login/secours")
async def login_secours_submit(
request: Request,
username: str = Form(""),
password: str = Form(""),
next: str = Form("/"),
csrf_token: str = Form(""),
):
settings = get_settings()
if not settings.auth_enabled:
return RedirectResponse("/", status_code=303)
if not _porte_secours_configuree(settings):
return render(
request, "error.html", status_code=404,
message="Page introuvable.",
)
if not _csrf_valid(request, csrf_token):
return render(
request, "login.html", status_code=403, mode_secours=True,
next=_safe_next(next), error="Session expirée — réessaie.",
)
identifiants_ok = secrets.compare_digest(
username, settings.porte_secours_user
) and secrets.compare_digest(password, settings.porte_secours_password)
compte = (
models.get_user_by_username(settings.porte_secours_user)
if identifiants_ok
else None
)
if compte is not None:
admin_ok = bool(compte["active"]) and compte["role"] == "admin"
else:
# Pas de ligne en base : seul l'opérateur d'environnement
# (administrateur synthétique) peut être le compte désigné.
admin_ok = settings.porte_secours_user == settings.auth_user
if not identifiants_ok or not admin_ok:
return render(
request, "login.html", status_code=401, mode_secours=True,
next=_safe_next(next), error="Identifiants incorrects.",
)
if compte is not None:
models.update_user(
compte["id"],
last_login_at=time.strftime(
"%Y-%m-%d %H:%M:%S", time.gmtime()
),
)
models.log_admin(compte["id"], "login.porte_secours", username)
return _ouvrir_session(settings.porte_secours_user, next)
@app.get("/logout") @app.get("/logout")
@@ -2090,11 +2201,18 @@ async def admin_settings_save(request: Request, csrf_token: str = Form("")):
if raw is None or str(raw).strip() == "": if raw is None or str(raw).strip() == "":
continue # champ vide = retour à la valeur d'environnement continue # champ vide = retour à la valeur d'environnement
try: try:
overrides[key] = ( if converter is bool:
str(raw).strip() # Booléen lu EXPLICITEMENT (0.46.6) : bool("false")
if converter is str # vaudrait True — un réglage booléen du panneau
else converter(str(raw).strip()) # (porte_centrale, sync_*, briefing_*) doit pouvoir
) # se désactiver en saisissant « false ».
overrides[key] = str(raw).strip().lower() in (
"1", "true", "on", "oui",
)
elif converter is str:
overrides[key] = str(raw).strip()
else:
overrides[key] = converter(str(raw).strip())
except (TypeError, ValueError): except (TypeError, ValueError):
continue continue
import json as _json import json as _json
+24
View File
@@ -19,6 +19,29 @@
{% if error %} {% if error %}
<p class="login-error" role="alert">{{ error }}</p> <p class="login-error" role="alert">{{ error }}</p>
{% endif %} {% endif %}
{% if mode_secours|default(false) %}
{# Porte de secours administrateur (0.46.6) : ce gabarit n'est
rendu que par le chemin discret /login/secours, jamais
proposé depuis l'écran principal. #}
<p class="login-sub">Porte de secours — administrateur désigné uniquement.</p>
<form method="post" action="/login/secours" class="login-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="next" value="{{ next }}">
<label>Nom d'utilisateur
<input type="text" name="username" autocomplete="username" required autofocus>
</label>
<label>Mot de passe
<input type="password" name="password" autocomplete="current-password" required>
</label>
<button type="submit" class="login-submit">Se connecter</button>
</form>
{% elif porte_centrale|default(false) %}
{# Porte centrale unique (0.46.6) : aucune entrée locale —
seule l'entrée centrale est présentée. #}
<p class="login-sub">Cette instance se connecte par la porte centrale de la suite Dracodev : ton compte du Hub ouvre Kanbante via ObsiGate, sans mot de passe local.</p>
<a class="login-sso" href="{{ sso_authorize_url }}">Se connecter avec ObsiGate (SSO)</a>
<p class="login-foot">Accès restreint — session de {{ 7 }} jours, déconnexion depuis le menu latéral.</p>
{% else %}
<form method="post" action="/login" class="login-form"> <form method="post" action="/login" class="login-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="next" value="{{ next }}"> <input type="hidden" name="next" value="{{ next }}">
@@ -32,6 +55,7 @@
</form> </form>
<a class="login-sso" href="{{ sso_authorize_url }}">Se connecter avec ObsiGate (SSO)</a> <a class="login-sso" href="{{ sso_authorize_url }}">Se connecter avec ObsiGate (SSO)</a>
<p class="login-foot">Accès restreint — session de {{ 7 }} jours, déconnexion depuis le menu latéral.</p> <p class="login-foot">Accès restreint — session de {{ 7 }} jours, déconnexion depuis le menu latéral.</p>
{% endif %}
</section> </section>
</main> </main>
</body> </body>
+81
View File
@@ -0,0 +1,81 @@
# Porte centrale unique (0.46.6)
Programme « compte partout / porte centrale unique » : un compte
créé au Hub n'a pas de mot de passe local (il vit chez ObsiGate) ;
les formulaires locaux des applications ne pourront donc jamais
l'accepter. La porte centrale fait de cette réalité le parcours
normal : une seule entrée présentée, celle de la suite.
## Le réglage
- **Nom** : `porte_centrale` (booléen).
- **Défaut à la livraison : désactivé.** Tant qu'il est inactif,
Kanbante se comporte exactement comme avant (formulaire local
+ bouton « Se connecter avec ObsiGate (SSO) »).
- **Activation** : un administrateur Kanbante le règle dans
**Administration → Paramètres** (ligne `porte_centrale` :
saisir `true` pour activer, `false` pour désactiver, vide pour
revenir à l'environnement). L'environnement peut aussi amorcer
la valeur : `KANBANTE_PORTE_CENTRALE=1` — l'environnement
amorce, le panneau gouverne, comme les autres réglages
surchargeables (`app.config.OVERRIDABLE_SETTINGS`).
## Mode actif
- L'écran de connexion (`GET /login`) ne présente **que**
l'entrée centrale : le lien « Se connecter avec ObsiGate
(SSO) », bien visible, et un texte qui explique qu'il n'y a
pas de mot de passe local. Aucun champ identifiant/mot de
passe n'est rendu.
- Le formulaire local (`POST /login`) **refuse** toute tentative
(403), quels que soient les identifiants présentés — mot de
passe de base comme repli d'environnement : les identifiants
locaux ordinaires ne sont plus acceptés sur cette voie.
- **Sessions déjà établies : aucun impact.** Le contrôle de
session (cookie signé) ne change pas ; une session ouverte
avant l'activation reste valide jusqu'à son expiration
normale, et le pont draco continue d'en ouvrir par le SSO.
- Non touchés : l'authentification Basic d'environnement (voie
scripts historique), les jetons personnels `kbt_`, le pont
`/auth/draco` et `/api/search`.
## Porte de secours administrateur
Pour ne jamais se verrouiller dehors (panne de l'émettrice,
liaison à réparer…), **un** compte administrateur local désigné
peut toujours se connecter — par un **chemin discret**, jamais
proposé ni lié sur l'écran principal :
- **Chemin** : `GET /login/secours` (formulaire), `POST
/login/secours` (connexion). Sans configuration, le chemin
répond 404, sans explication.
- **Désignation, par l'environnement uniquement** (secrets —
jamais en base, jamais dans le panneau, jamais en dur) :
- `KANBANTE_PORTE_SECOURS_USER` : nom du compte administrateur
local désigné ;
- `KANBANTE_PORTE_SECOURS_PASSWORD` : son mot de passe de
secours (comparé en temps constant ; il peut différer du mot
de passe de base du compte — c'est un identifiant dédié).
- **Gardes** : les deux variables doivent être présentes ; le
compte désigné doit exister en base, être **actif** et de rôle
**admin** (ou être l'opérateur d'environnement, administrateur
synthétique). Un membre désigné par erreur ne passe pas.
L'ouverture de session par cette voie est journalisée
(`login.porte_secours`).
- La session ouverte est une session native ordinaire
(7 jours) : l'administrateur peut ensuite désactiver la porte
centrale dans le panneau, lier un `sub` draco, réparer une
correspondance, etc.
## Fiche d'activation (résumé opérateur)
1. Poser `KANBANTE_PORTE_SECOURS_USER` et
`KANBANTE_PORTE_SECOURS_PASSWORD` dans l'environnement du
conteneur Kanbante, recréer le conteneur.
2. Vérifier la porte de secours : ouvrir `/login/secours`,
se connecter avec le compte désigné.
3. Activer : Administration → Paramètres →
`porte_centrale` = `true` → Enregistrer.
4. Vérifier : déconnecté, `/login` ne montre que l'entrée
ObsiGate (SSO) ; un essai de formulaire local est refusé ;
les sessions déjà ouvertes restent valides.
+216
View File
@@ -0,0 +1,216 @@
"""Porte centrale unique (0.46.6) — réglage, écran, refus, secours.
Branche de la phase 2 du programme « compte partout » (basée sur
la 0.46.5, fusion #145/#144). Couvert ici, sur instance fraîche
par test (base temporaire : seul le compte d'amorçage « proprio »,
admin, créé depuis l'environnement — même patron que
tests/test_amorcage_pont.py) :
- mode INACTIF (défaut à la livraison) : comportement actuel —
formulaire local visible et fonctionnel ;
- activation par le PANNEAU d'administration (le réglage
``porte_centrale`` se pose et se retire : ``true`` puis
``false`` — la conversion booléenne explicite de
l'enregistrement est exercée au passage) ;
- mode ACTIF : l'écran ne présente que l'entrée centrale (lien
SSO, aucun champ local) ; le formulaire local est refusé même
avec des identifiants locaux valides ; une session déjà
établie reste valide (aucun impact) ;
- porte de secours : chemin discret ``/login/secours``, jamais
proposé sur l'écran principal ; identifiants posés par les
variables d'environnement ``KANBANTE_PORTE_SECOURS_USER`` /
``KANBANTE_PORTE_SECOURS_PASSWORD`` ; seul le compte
administrateur désigné passe (un membre désigné est refusé) ;
sans les variables, le chemin répond 404.
"""
import json
import pytest
from fastapi.testclient import TestClient
import app.main as main
from app import accounts, models
from app.config import get_settings
MDP_AMORCE = "mdp-amorce"
MDP_MEMBRE = "mdp-membre"
MDP_SECOURS = "mdp-secours-test"
@pytest.fixture()
def instance(monkeypatch, tmp_path, fake_gitea):
monkeypatch.setenv("KANBANTE_DB_PATH", str(tmp_path / "porte.db"))
monkeypatch.setenv("KANBANTE_AUTH_USER", "proprio")
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", MDP_AMORCE)
with TestClient(main.app) as client:
users = models.list_users()
assert len(users) == 1 and users[0]["username"] == "proprio"
membre_id = models.create_user(
"membre1", accounts.hash_password(MDP_MEMBRE),
role="member", display_name="Membre Un",
)
yield client, users[0], membre_id
def _csrf(client, chemin="/login"):
client.get(chemin)
return client.cookies.get("kanbante_csrf") or ""
def _connecter(client, username, password, chemin="/login"):
token = _csrf(client, chemin)
return client.post(
chemin,
data={"username": username, "password": password,
"next": "/", "csrf_token": token},
follow_redirects=False,
)
def _activer_porte_par_le_panneau(client):
"""Connexion admin native, puis POST /admin/settings (true)."""
r = _connecter(client, "proprio", MDP_AMORCE)
assert r.status_code == 303
r = client.get("/admin/settings")
assert r.status_code == 200
assert "porte_centrale" in r.text # le réglage est au panneau
token = client.cookies.get("kanbante_csrf") or ""
r = client.post(
"/admin/settings",
data={"setting_porte_centrale": "true", "csrf_token": token},
follow_redirects=False,
)
assert r.status_code == 303
assert get_settings().porte_centrale is True
# ── Mode inactif : comportement actuel ───────────────────────
def test_mode_inactif_par_defaut_comportement_actuel(instance):
client, _amorce, _membre = instance
assert get_settings().porte_centrale is False
r = client.get("/login")
assert r.status_code == 200
assert 'name="password"' in r.text # formulaire local présent
assert "Se connecter avec ObsiGate (SSO)" in r.text
# Le formulaire local fonctionne pour un compte ordinaire.
r = _connecter(client, "membre1", MDP_MEMBRE)
assert r.status_code == 303
assert "kanbante_session" in r.headers.get("set-cookie", "")
assert client.get("/").status_code == 200
def test_panneau_active_puis_desactive(instance):
client, _amorce, _membre = instance
_activer_porte_par_le_panneau(client)
# Désactivation : saisir « false » doit RÉELLEMENT désactiver
# (conversion booléenne explicite, pas bool("false") == True).
token = client.cookies.get("kanbante_csrf") or ""
r = client.post(
"/admin/settings",
data={"setting_porte_centrale": "false", "csrf_token": token},
follow_redirects=False,
)
assert r.status_code == 303
assert get_settings().porte_centrale is False
# Et le formulaire refonctionne (session admin refermée).
client.cookies.delete("kanbante_session")
r = _connecter(client, "membre1", MDP_MEMBRE)
assert r.status_code == 303
# ── Mode actif : écran central, formulaire refusé ────────────
def test_mode_actif_ecran_et_formulaire_refuse(instance):
client, _amorce, _membre = instance
_activer_porte_par_le_panneau(client)
# La session DÉJÀ ÉTABLIE (celle de l'admin qui vient
# d'activer) n'est pas touchée par l'activation.
assert client.get("/").status_code == 200
assert client.get("/admin/settings").status_code == 200
# Écran de connexion, visiteur déconnecté : entrée centrale seule.
client.cookies.delete("kanbante_session")
r = client.get("/login")
assert r.status_code == 200
assert "Se connecter avec ObsiGate (SSO)" in r.text
assert 'name="password"' not in r.text
assert 'action="/login"' not in r.text
# Le chemin de secours n'est JAMAIS proposé sur l'écran principal.
assert "/login/secours" not in r.text
# Formulaire local refusé, même avec les identifiants valides
# d'un compte ordinaire…
r = _connecter(client, "membre1", MDP_MEMBRE)
assert r.status_code == 403
assert "porte centrale est active" in r.text
assert "kanbante_session" not in r.headers.get("set-cookie", "")
# …et même avec ceux de l'administrateur d'amorçage.
r = _connecter(client, "proprio", MDP_AMORCE)
assert r.status_code == 403
assert "kanbante_session" not in r.headers.get("set-cookie", "")
# ── Porte de secours ─────────────────────────────────────────
def test_porte_secours_chemin_discret(instance, monkeypatch):
client, _amorce, _membre = instance
monkeypatch.setenv("KANBANTE_PORTE_SECOURS_USER", "proprio")
monkeypatch.setenv("KANBANTE_PORTE_SECOURS_PASSWORD", MDP_SECOURS)
_activer_porte_par_le_panneau(client)
client.cookies.delete("kanbante_session")
# L'écran principal ne propose toujours pas le chemin.
assert "/login/secours" not in client.get("/login").text
# Le chemin discret, lui, présente son formulaire…
r = client.get("/login/secours")
assert r.status_code == 200
assert 'action="/login/secours"' in r.text
# …refuse un mauvais mot de passe…
r = _connecter(client, "proprio", "mauvais", chemin="/login/secours")
assert r.status_code == 401
assert "kanbante_session" not in r.headers.get("set-cookie", "")
# …refuse le compte membre (pas le compte désigné)…
r = _connecter(client, "membre1", MDP_SECOURS, chemin="/login/secours")
assert r.status_code == 401
# …et ouvre la session de l'administrateur désigné, en plein
# mode porte centrale.
r = _connecter(client, "proprio", MDP_SECOURS, chemin="/login/secours")
assert r.status_code == 303
assert "kanbante_session" in r.headers.get("set-cookie", "")
assert client.get("/admin").status_code == 200
def test_porte_secours_membre_designe_refuse(instance, monkeypatch):
"""Le compte désigné doit être administrateur : un membre ne passe pas."""
client, _amorce, _membre = instance
monkeypatch.setenv("KANBANTE_PORTE_SECOURS_USER", "membre1")
monkeypatch.setenv("KANBANTE_PORTE_SECOURS_PASSWORD", MDP_SECOURS)
r = _connecter(client, "membre1", MDP_SECOURS, chemin="/login/secours")
assert r.status_code == 401
assert "kanbante_session" not in r.headers.get("set-cookie", "")
def test_porte_secours_non_configuree_404(instance):
client, _amorce, _membre = instance
# Aucune variable de secours dans l'environnement de test.
assert client.get("/login/secours").status_code == 404
r = _connecter(client, "proprio", MDP_AMORCE, chemin="/login/secours")
assert r.status_code == 404
assert "kanbante_session" not in r.headers.get("set-cookie", "")
def test_activation_par_variable_environnement(monkeypatch, tmp_path, fake_gitea):
"""L'environnement peut amorcer le réglage (défaut officiel : inactif)."""
monkeypatch.setenv("KANBANTE_DB_PATH", str(tmp_path / "porte-env.db"))
monkeypatch.setenv("KANBANTE_AUTH_USER", "proprio")
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", MDP_AMORCE)
monkeypatch.setenv("KANBANTE_PORTE_CENTRALE", "1")
with TestClient(main.app) as client:
assert get_settings().porte_centrale is True
r = client.get("/login")
assert 'name="password"' not in r.text
# La surcharge du panneau reste possible par-dessus :
# (kv settings_overrides, même écriture que le panneau).
models.set_kv(
"settings_overrides", json.dumps({"porte_centrale": False})
)
assert get_settings().porte_centrale is False