Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31fe0c181a | ||
|
|
931e4d740b | ||
|
|
8915c7ddd1 | ||
|
|
0c3b6c522f | ||
|
|
6ce408059c | ||
|
|
fcc6cdf830 | ||
|
|
e6f5cff870 | ||
|
|
bd5357f589 |
@@ -1,3 +1,30 @@
|
||||
## v7.85.5 (2026-11-06) — Inscription locale fermée par défaut + échec de connexion qui nomme la voie SSO (fusion #146, #144)
|
||||
|
||||
- **#146 — l'inscription publique locale n'est plus ouverte par défaut** : nouveau réglage d'administration « inscription locale ouverte » (`app/services/reglages_acces.py`, patron `llm_config`/`sso_config` : ligne unique en base qui prime sur la variable `INSCRIPTION_LOCALE_OUVERTE`, défaut **fermée** — décision de Bruno : les comptes naissent dans le Hub Dracodev). Réversible à tout moment par un administrateur via `GET`/`PUT /api/admin/reglages-acces`.
|
||||
- **Quand elle est fermée** : `POST /auth/register` répond 403 et `GET /auth/register` affiche une page de refus propre en français — les comptes se créent dans le Hub Dracodev, avec le lien du Hub dérivé de l'hôte (`draco_urls.hub_url()`, comme les autres liens de la suite) ; l'onglet Register disparaît de l'écran de connexion (avis nommant le Hub à la place) et les boutons « Get started » de la page d'accueil cèdent la place au lien Hub. **La connexion des comptes existants, locaux comme draco, n'est jamais affectée.**
|
||||
- **#144 (part Flowdeck)** : l'échec du formulaire de connexion locale (« Invalid credentials », 401) suggère désormais la voie centrale : « Si votre compte a été créé dans le Hub Dracodev, connectez-vous avec le bouton « Login with ObsiGate (SSO) »… » — le bouton existant, dont le libellé est repris tel quel. Aucune logique d'authentification ne change.
|
||||
- **Tests** : `tests/test_reglages_acces.py` (nouveau, 12 tests) — fermée par défaut (défaut du code vérifié), POST refusé 403 + message + aucun compte créé, page de refus, onglet Register absent, ouverte = inscription d'aujourd'hui, connexion des comptes existants (local et draco) inchangée ouvert/fermé, échec local nommant ObsiGate (SSO), aller-retour de l'API d'administration et refus aux anonymes. Le harnais de test ouvre explicitement l'inscription pour les suites historiques qui créent leurs comptes par `/auth/register` (conftest et les fixtures locales des suites historiques, commenté).
|
||||
|
||||
## v7.85.4 (2026-11-06) — Barre commune v0.6.0 re-vendangée : entrée « Administration » (fusion #142)
|
||||
|
||||
- **Re-vendange verbatim** de la barre commons v0.6.0 (PR dracodev-commons #14) : `static/js/vendor/draco-barre.js` + `static/css/draco-barre.css`. Le menu compte gagne l'entrée **Administration** → `<hub>/admin` (le Hub redirige vers `/admin/utilisateurs`), à **visibilité conditionnelle** (affirmée par l'hôte via `cfg.admin` / `DRACO_BAR.setAdmin`, jamais déduite par la barre).
|
||||
- **Pas de drapeau chez Flowdeck** : Flowdeck ne connaît que le rôle du registre du Hub pour sa propre application (consulté au pont draco avant le JIT) — pas le rôle global de suite (rôle du compte chez ObsiGate, la définition de la garde du Hub). À défaut de certitude, le contrat v0.6.0 impose de ne pas affirmer : l'entrée reste masquée dans Flowdeck (elle apparaît chez ObsiGate et dans la console, qui peuvent l'établir). Commentaire posé dans le gabarit `_draco_bar.html`.
|
||||
- **Test** : `tests/test_draco_bar_texte.py::test_barre_v060_entree_administration_vendangee` (marqueurs de la vendange dans les assets servis).
|
||||
|
||||
## v7.85.3 (2026-11-06) — Déconnexion du menu compte branchée (fusion #141)
|
||||
|
||||
- **Défaut corrigé** : le bouton « Déconnexion » du menu compte de la barre commune ne faisait rien — la barre relaie l'action `deconnexion` à l'hôte et le `onAction` du gabarit (`app/templates/_draco_bar.html`) n'avait aucune branche pour elle (même défaut que la console Spark, l'hôte du signalement de Bruno).
|
||||
- **Branchement** : nouvelle branche `deconnexion` — destruction d'abord de la session locale Flowdeck (`fetch GET /auth/logout`, le même point de sortie que les liens « Log out » existants), puis navigation vers la déconnexion globale de l'émettrice (`GET {draco_urls().obsigate}/sso/logout?return_to={origine}/`, nouvelle route ObsiGate 2.86.0, PR sœur) : le `jti` draco y est révoqué et le cookie effacé sur le domaine parent. Après le parcours, la session de la suite entière et la session locale Flowdeck sont terminées ; le retour se fait sur la racine Flowdeck (page de connexion).
|
||||
- **Test** : `tests/test_draco_bar_texte.py::test_barre_deconnexion_branchee_globale` (marqueurs du branchement dans le gabarit). Périmètre draco/barre comparé à main vierge dans le même environnement : fonds identique (2 échecs `test_draco_urls_devprod` et 17 erreurs de dépendances absentes de l'environnement local, préexistants), +1 test passant.
|
||||
|
||||
## v7.85.2 (2026-11-06) — HUB-19 : propagation des rôles du Hub (registre D3(a))
|
||||
|
||||
- **Client des rôles du Hub** (`app/services/hub_roles.py`, nouveau) : portage Python du pilote ShaarIt 0.7.0 (`hub_roles.rs`). À l'ouverture de session draco, consultation de `GET {hub}/api/roles/flowdeck` — en-tête Cookie complet de la requête relayé tel quel, `Accept: application/json`, délai 2 s. URL du Hub résolue comme la barre commune (`draco_urls.hub_url()` : explicite `HUB_URL` > hôte de la requête > dérivation par l'hôte de l'agent > défaut officiel), jamais d'URL dev figée. Cache mémoire par `sub`, TTL 60 s, horodatage posé **avant** l'appel : un échec conserve l'ancien cache, pas de rafale. Toute erreur (401/404/réseau/JSON illisible/rôle hors ensemble {`aucun`, `utilisateur`, `administrateur`}) = rôle **inconnu** → comportement actuel conservé, intégralement.
|
||||
- **Refus `aucun` avant la session** : le pont `GET /auth/draco` (seul point où un cookie draco aboutit à une session locale) appelle `user_for_draco(..., refuser_aucun=True)` ; un rôle connu `aucun` lève `draco.RoleAucunRefuse` **avant** la résolution de compte et donc avant le provisionnement JIT HUB-15 — aucun compte ni lien n'est créé pour un `sub` refusé. Réponse 403 en français nommant le registre des rôles du Hub. Exemptions (patron du pilote) : session locale préexistante (accès de secours par mot de passe, le pont ne refuse pas), page de connexion, santé et statiques (jamais passées par le pont), contrats API propres (OAuth externe Gitea/GitHub, jetons Bearer/SCIM de Flowdeck — non draco, intacts) et recherche fédérée (`/api/search`, repli draco sans création de session : défaut `refuser_aucun=False`, aucun refus nouveau).
|
||||
- **Surfaces d'administration d'instance gardées** (`hub_roles.admin_hub_ok` : session draco + rôle connu → `administrateur` exigé ; sinon comportement actuel) : `/api/admin/*` (`admin_required`), configuration des fournisseurs de l'agent (`agent._current_admin`), journal d'audit unifié (branche session de `audit._admin_user`), administration SCIM/domaines (branche session de `scim._admin_session`), configuration SSO (branche session de `sso._require_admin`). Les voies Bearer de ces mêmes surfaces (contrats d'API propres) et l'administration d'espace de travail (rôles de membre) ne sont pas touchées.
|
||||
- **Provisionnement JIT inchangé** (HUB-15) : le refus intervient au même point que l'établissement de session, avant toute création.
|
||||
- Tests : `tests/test_hub_roles.py` (nouveau, 28 tests — client : contrat exact de l'appel, cache par `sub`, ancien cache après échec, erreurs → inconnu ; intégration : 403 français au pont, refus avant JIT — compte et lien absents —, rôle `utilisateur` avec JIT intact, repli rôle inconnu, session préexistante exemptée, garde `/api/admin/*` selon le rôle). Balayage draco/SSO/recherche existant reconfirmé sans régression (voir PR).
|
||||
|
||||
## v7.85.1 (2026-11-06) — Barre commune v0.5.0 : thème commun (HUB-18, fusion #132)
|
||||
|
||||
> **Hypothèse d'ordre** : cette version suppose la fusion préalable de la PR HUB-15 #51 (Flowdeck 7.85.0) ; branchée sur `main` (7.84.15), elle vise **7.85.1**.
|
||||
|
||||
@@ -46,6 +46,13 @@ class Settings(BaseSettings):
|
||||
log_level: str = "INFO"
|
||||
default_lang: str = "fr"
|
||||
|
||||
# Réglages d'accès (fusion #146) — défauts repris par
|
||||
# `app/services/reglages_acces.py` ; la ligne enregistrée en base
|
||||
# par un administrateur prime sur ces valeurs.
|
||||
# Inscription publique locale : fermée par défaut (décision de
|
||||
# Bruno — les comptes naissent dans le Hub Dracodev).
|
||||
inscription_locale_ouverte: bool = False
|
||||
|
||||
# Rate limiting
|
||||
rate_limit_enabled: bool = True
|
||||
rate_limit_requests: int = 60 # per minute
|
||||
|
||||
+1
-1
@@ -216,7 +216,7 @@ def _lire_version() -> str:
|
||||
|
||||
|
||||
# Littéral de version synchronisé avec le fichier VERSION :
|
||||
# version="7.85.1"
|
||||
# version="7.85.2"
|
||||
# Patron historique des bumps (voir scripts/bump_version.py, qui cherche
|
||||
# ce littéral) et contrôle de tests/test_pwa_offline.py. La version
|
||||
# runtime reste lue dynamiquement depuis VERSION par _lire_version()
|
||||
|
||||
@@ -11,6 +11,16 @@ async def admin_required(request: Request):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
# HUB-19 (registre des rôles du Hub) : session draco + rôle connu
|
||||
# pour Flowdeck → exiger « administrateur » dans le registre,
|
||||
# en plus du drapeau local. Pas de session draco ou rôle inconnu
|
||||
# (Hub injoignable) : comportement actuel.
|
||||
from app.services import hub_roles
|
||||
if not hub_roles.admin_hub_ok(request):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Niveau « administrateur » requis dans le registre "
|
||||
"des rôles du Hub pour FlowDeck")
|
||||
# Also check DB directly (session cookie may be stale)
|
||||
if not user.get("is_admin"):
|
||||
from app.db import get_conn
|
||||
@@ -136,6 +146,30 @@ def delete_user(user_id: int, _admin=Depends(admin_required)):
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ── Réglages d'accès (fusion #146) ──
|
||||
@router.get("/reglages-acces")
|
||||
def get_reglages_acces(_admin=Depends(admin_required)):
|
||||
"""Réglages d'accès effectifs de l'instance (administrateurs)."""
|
||||
from app.services import reglages_acces
|
||||
return reglages_acces.get_reglages_acces()
|
||||
|
||||
|
||||
@router.put("/reglages-acces")
|
||||
def put_reglages_acces(_admin=Depends(admin_required), body: dict = Body(default={})):
|
||||
"""Modifie les réglages d'accès — réversible à tout moment.
|
||||
|
||||
Corps accepté : ``{"inscription_locale_ouverte": true|false}``.
|
||||
Fermée (défaut) : aucun compte local ne se crée sur l'instance,
|
||||
les comptes naissent dans le Hub Dracodev. La connexion des
|
||||
comptes existants n'est jamais affectée.
|
||||
"""
|
||||
from app.services import reglages_acces
|
||||
if "inscription_locale_ouverte" in body:
|
||||
return reglages_acces.set_reglages_acces(
|
||||
inscription_locale_ouverte=bool(body["inscription_locale_ouverte"]))
|
||||
return reglages_acces.get_reglages_acces()
|
||||
|
||||
|
||||
# ── Stats ──
|
||||
@router.get("/stats")
|
||||
def user_stats(_admin=Depends(admin_required)):
|
||||
|
||||
@@ -127,6 +127,14 @@ def _current_admin(request: Request) -> dict:
|
||||
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user.get("id"),)).fetchone()
|
||||
if not row or not row["is_admin"]:
|
||||
raise HTTPException(status_code=403, detail="Accès administrateur requis")
|
||||
# HUB-19 : session draco + rôle Hub connu → exiger « administrateur »
|
||||
# dans le registre du Hub (voir app.services.hub_roles.admin_hub_ok).
|
||||
from app.services import hub_roles
|
||||
if not hub_roles.admin_hub_ok(request):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Niveau « administrateur » requis dans le registre "
|
||||
"des rôles du Hub pour FlowDeck")
|
||||
return user
|
||||
|
||||
|
||||
|
||||
@@ -27,6 +27,14 @@ def _admin_user(request: Request) -> dict:
|
||||
row = conn.execute("SELECT is_admin FROM users WHERE id=?",
|
||||
(sess.get("id"),)).fetchone()
|
||||
if row and row["is_admin"]:
|
||||
# HUB-19 : session draco + rôle Hub connu → exiger
|
||||
# « administrateur » dans le registre du Hub (la voie
|
||||
# Bearer ci-dessous, contrat d'API propre, est exemptée).
|
||||
from app.services import hub_roles
|
||||
if not hub_roles.admin_hub_ok(request):
|
||||
raise HTTPException(
|
||||
403, "Niveau « administrateur » requis dans le "
|
||||
"registre des rôles du Hub pour FlowDeck")
|
||||
return sess
|
||||
raise HTTPException(403, "Admin required")
|
||||
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
|
||||
|
||||
+124
-5
@@ -51,6 +51,76 @@ def _sso_login_url() -> str:
|
||||
f"?return_to={quote(retour, safe='')}")
|
||||
|
||||
|
||||
# Échec de connexion locale (fusion #144) : le message suggère la voie
|
||||
# centrale — un compte créé dans le Hub Dracodev n'a pas de mot de
|
||||
# passe local, son entrée est le bouton « Login with ObsiGate (SSO) »
|
||||
# du même écran (libellé repris tel quel).
|
||||
MESSAGE_ECHEC_CONNEXION_LOCALE = (
|
||||
"Identifiants invalides. Si votre compte a été créé dans le Hub "
|
||||
"Dracodev, connectez-vous avec le bouton « Login with ObsiGate "
|
||||
"(SSO) » de cet écran : les comptes de la suite n'ont pas de mot "
|
||||
"de passe local."
|
||||
)
|
||||
|
||||
|
||||
def _page_inscription_fermee() -> str:
|
||||
"""Page de refus propre du parcours d'inscription fermé (#146)."""
|
||||
from app.services import draco_urls, reglages_acces
|
||||
|
||||
hub = draco_urls.hub_url()
|
||||
message = reglages_acces.message_inscription_fermee()
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FlowDeck — Inscription fermée</title>
|
||||
<style>
|
||||
body{{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;}}
|
||||
.box{{background:#222;border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:40px;width:100%;max-width:420px;}}
|
||||
.box h1{{font-size:22px;margin:0 0 12px;}}
|
||||
.box p{{color:rgba(255,255,255,.6);font-size:14px;line-height:1.6;margin:0 0 16px;}}
|
||||
.box a{{color:#2383E2;}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<h1>Inscription fermée</h1>
|
||||
<p>{message}</p>
|
||||
<p><a href="{hub}">Ouvrir le Hub Dracodev</a> ·
|
||||
<a href="/auth/login?provider=local">Retour à la connexion</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def _rendre_page_connexion(html: str) -> str:
|
||||
"""Rend la page de connexion en tenant compte du réglage #146.
|
||||
|
||||
Inscription fermée : l'onglet Register disparaît et un avis nomme
|
||||
le Hub Dracodev (lien dérivé de l'hôte, comme les autres liens de
|
||||
la suite). Inscription ouverte : page intégralement inchangée.
|
||||
"""
|
||||
from app.services import draco_urls, reglages_acces
|
||||
|
||||
if reglages_acces.inscription_locale_ouverte():
|
||||
return _with_nonce(html)
|
||||
html = html.replace(
|
||||
"""<button class="tab" onclick="switchTab('register')" id="tab-register">Register</button>""",
|
||||
"",
|
||||
)
|
||||
avis = (
|
||||
'<div class="success" style="display:block;'
|
||||
'background:rgba(35,131,226,.15);color:#7db8f0">'
|
||||
"Pas encore de compte ? Les comptes se créent dans le "
|
||||
f'<a href="{draco_urls.hub_url()}" style="color:#7db8f0">'
|
||||
"Hub Dracodev</a>, puis se connectent ici avec le bouton "
|
||||
"« Login with ObsiGate (SSO) ».</div>"
|
||||
)
|
||||
html = html.replace('<div id="expired-msg"', avis + '<div id="expired-msg"', 1)
|
||||
return _with_nonce(html)
|
||||
|
||||
|
||||
def _with_nonce(html: str) -> str:
|
||||
"""A20 : injecte le nonce CSP au moment du rendu.
|
||||
|
||||
@@ -180,7 +250,15 @@ async function handleLogin(e){e.preventDefault();const email=document.getElement
|
||||
|
||||
@router.get("/register")
|
||||
def register_page(request: Request):
|
||||
"""Show the registration page (local login page with register tab active)."""
|
||||
"""Show the registration page (local login page with register tab active).
|
||||
|
||||
Inscription fermée (#146, défaut) : refus propre — page en
|
||||
français nommant le Hub Dracodev, là où les comptes se créent.
|
||||
"""
|
||||
from app.services import reglages_acces
|
||||
|
||||
if not reglages_acces.inscription_locale_ouverte():
|
||||
return HTMLResponse(_page_inscription_fermee(), status_code=403)
|
||||
return HTMLResponse(_with_nonce(LOCAL_LOGIN_HTML.replace(
|
||||
'class="tab active" onclick="switchTab(\'login\')"',
|
||||
'class="tab" onclick="switchTab(\'login\')"'
|
||||
@@ -205,7 +283,7 @@ def login(request: Request, provider: str = Query("gitea")):
|
||||
# Local login page (POST handled by /auth/local-login)
|
||||
from fastapi.responses import HTMLResponse
|
||||
if provider == "local":
|
||||
return HTMLResponse(_with_nonce(LOCAL_LOGIN_HTML), status_code=200)
|
||||
return HTMLResponse(_rendre_page_connexion(LOCAL_LOGIN_HTML), status_code=200)
|
||||
|
||||
# OAuth flow — check if provider is configured
|
||||
from app.auth.providers import get_provider
|
||||
@@ -259,6 +337,18 @@ def register(request: Request, body: dict = Body(default={})):
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"error": "Password must be at least 6 characters"}, status_code=400)
|
||||
|
||||
# Inscription locale fermée (fusion #146, défaut) : les comptes
|
||||
# naissent dans le Hub Dracodev — aucun compte local ne se crée
|
||||
# ici. La connexion des comptes existants n'est pas touchée.
|
||||
from app.services import draco_urls, reglages_acces
|
||||
if not reglages_acces.inscription_locale_ouverte():
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse(
|
||||
{"error": reglages_acces.message_inscription_fermee(),
|
||||
"hub_url": draco_urls.hub_url()},
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
# SSO-only instance (v6.7.0): local registration is refused — accounts are
|
||||
# auto-provisioned by the IdP instead (admins still come from Settings).
|
||||
from app.services.sso_provisioning import is_sso_only
|
||||
@@ -313,7 +403,7 @@ def local_login(request: Request, body: dict = Body(default={})):
|
||||
with get_conn() as conn:
|
||||
user = conn.execute("SELECT * FROM users WHERE login=?", (email,)).fetchone()
|
||||
if not user:
|
||||
return JSONResponse({"error": "Invalid credentials"}, status_code=401)
|
||||
return JSONResponse({"error": MESSAGE_ECHEC_CONNEXION_LOCALE}, status_code=401)
|
||||
|
||||
ud = dict(user)
|
||||
if not ud.get("is_active"):
|
||||
@@ -332,7 +422,7 @@ def local_login(request: Request, body: dict = Body(default={})):
|
||||
(attempts, lock, ud["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
return JSONResponse({"error": "Invalid credentials"}, status_code=401)
|
||||
return JSONResponse({"error": MESSAGE_ECHEC_CONNEXION_LOCALE}, status_code=401)
|
||||
|
||||
# Successful local login — SSO-only instances keep a way in for admins
|
||||
# only (every other account must use the IdP, design §7.1).
|
||||
@@ -483,10 +573,39 @@ def draco_bridge(request: Request, next: str = "/workspaces"):
|
||||
Point de retour de ``GET /sso/authorize`` de l'émettrice : si le
|
||||
cookie est valide et résolu vers un compte local, une session
|
||||
Flowdeck native est créée ; sinon, retour à la connexion locale.
|
||||
|
||||
HUB-19 (registre des rôles du Hub, contrat D3(a)) : rôle connu
|
||||
``aucun`` pour Flowdeck → refus 403 en français nommant le
|
||||
registre du Hub, **avant** la création de la session locale et
|
||||
avant tout provisionnement JIT (aucun compte créé pour un
|
||||
``sub`` refusé). Exemption, patron du pilote ShaarIt : une
|
||||
session locale préexistante (accès de secours) n'est pas
|
||||
refusée — le pont la laisse simplement se poursuivre. Rôle
|
||||
inconnu (Hub injoignable/erreur) : comportement actuel.
|
||||
"""
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.services import draco
|
||||
|
||||
user = draco.user_for_draco(request)
|
||||
_cookie_session = request.cookies.get("flowdeck_session", "")
|
||||
session_existante = (SessionManager.decode_session(_cookie_session)
|
||||
if _cookie_session else None)
|
||||
try:
|
||||
user = draco.user_for_draco(
|
||||
request, refuser_aucun=not session_existante)
|
||||
except draco.RoleAucunRefuse:
|
||||
return HTMLResponse(
|
||||
"""<!DOCTYPE html><html lang="fr"><head><meta charset="utf-8">
|
||||
<title>Accès refusé — FlowDeck</title></head><body><main>
|
||||
<h1>Accès refusé</h1>
|
||||
<p>Le registre des rôles de la suite (Hub Dracodev) attribue le niveau
|
||||
« aucun » à votre compte pour FlowDeck : l'accès par la session draco
|
||||
est refusé. L'administrateur de suite peut modifier ce niveau dans la
|
||||
matrice des rôles du Hub. L'accès de secours par le mot de passe local
|
||||
de l'instance reste possible par la page de connexion.</p>
|
||||
</main></body></html>""",
|
||||
status_code=403,
|
||||
)
|
||||
if user is None:
|
||||
return RedirectResponse(url="/auth/login?provider=local", status_code=302)
|
||||
session = SessionManager.create_session(user, request)
|
||||
|
||||
@@ -27,6 +27,22 @@ router = APIRouter(tags=["dashboard"])
|
||||
|
||||
|
||||
|
||||
|
||||
def _contexte_accueil() -> dict:
|
||||
"""Contexte de la page d'accueil publique (fusion #146).
|
||||
|
||||
Inscription fermée (défaut) : les boutons « Get started » de
|
||||
l'accueil cèdent la place à un lien vers le Hub Dracodev, là où
|
||||
les comptes se créent (URL dérivée de l'hôte, comme les autres
|
||||
liens de la suite).
|
||||
"""
|
||||
from app.services import draco_urls, reglages_acces
|
||||
|
||||
return {
|
||||
"inscription_ouverte": reglages_acces.inscription_locale_ouverte(),
|
||||
"hub_url": draco_urls.hub_url(),
|
||||
}
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def dashboard(
|
||||
request: Request,
|
||||
@@ -46,13 +62,13 @@ async def dashboard(
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("landing.html")
|
||||
return template.render()
|
||||
return template.render(**_contexte_accueil())
|
||||
except Exception:
|
||||
logger.exception("dashboard")
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("landing.html")
|
||||
return template.render()
|
||||
return template.render(**_contexte_accueil())
|
||||
|
||||
# ── Authenticated ──
|
||||
user_id = user.get("id", 1)
|
||||
|
||||
@@ -46,6 +46,14 @@ def _admin_session(request: Request) -> dict:
|
||||
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user["id"],)).fetchone()
|
||||
if not row or not row["is_admin"]:
|
||||
raise HTTPException(403, "Admin required")
|
||||
# HUB-19 : session draco + rôle Hub connu → exiger « administrateur »
|
||||
# dans le registre du Hub (les jetons SCIM Bearer, contrat propre,
|
||||
# passent par _scim_guard et sont exemptés).
|
||||
from app.services import hub_roles
|
||||
if not hub_roles.admin_hub_ok(request):
|
||||
raise HTTPException(
|
||||
403, "Niveau « administrateur » requis dans le registre "
|
||||
"des rôles du Hub pour FlowDeck")
|
||||
return user
|
||||
|
||||
|
||||
|
||||
@@ -527,6 +527,15 @@ def _require_admin(request: Request, *, write: bool) -> dict:
|
||||
).fetchone()
|
||||
if not row or not row["is_admin"]:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
# HUB-19 : session draco + rôle Hub connu → exiger « administrateur »
|
||||
# dans le registre du Hub (la voie Bearer ci-dessus, contrat d'API
|
||||
# propre, est exemptée).
|
||||
from app.services import hub_roles
|
||||
if not hub_roles.admin_hub_ok(request):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Niveau « administrateur » requis dans le registre "
|
||||
"des rôles du Hub pour FlowDeck")
|
||||
if write and request.method in ("POST", "PUT", "PATCH", "DELETE"):
|
||||
cookie = request.cookies.get("csrf_token", "")
|
||||
header = request.headers.get("X-CSRF-Token", "")
|
||||
|
||||
+33
-1
@@ -26,6 +26,12 @@ admin), aucun mot de passe local (``password_hash`` NULL,
|
||||
connexion locale par mot de passe reste impossible pour ce compte
|
||||
et inchangée pour les autres). Une pierre tombale (dissociation
|
||||
explicite) interdit toute re-création automatique pour ce ``sub``.
|
||||
|
||||
HUB-19 (registre des rôles du Hub) : le pont d'ouverture de session
|
||||
peut demander le refus d'un ``sub`` au rôle connu ``aucun``
|
||||
(``user_for_draco(..., refuser_aucun=True)`` →
|
||||
``RoleAucunRefuse``), levé avant la résolution de compte et le
|
||||
provisionnement JIT — voir ``app.services.hub_roles``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -45,6 +51,16 @@ logger = logging.getLogger(__name__)
|
||||
COOKIE_NAME = "draco_session"
|
||||
_REVOCATIONS_TTL = 60 # secondes de cache, comme le prévoit le design
|
||||
|
||||
|
||||
class RoleAucunRefuse(Exception):
|
||||
"""HUB-19 : le registre des rôles du Hub attribue le niveau
|
||||
``aucun`` à ce ``sub`` pour Flowdeck — l'ouverture de session
|
||||
draco est refusée (403), avant tout provisionnement JIT."""
|
||||
|
||||
def __init__(self, sub: str):
|
||||
super().__init__(sub)
|
||||
self.sub = sub
|
||||
|
||||
_revoked_cache: dict[str, int] = {}
|
||||
_revoked_fetched_at: float = 0.0
|
||||
|
||||
@@ -215,12 +231,22 @@ def _provision_jit(conn, claims: dict) -> dict | None:
|
||||
return dict(row) if row is not None else None
|
||||
|
||||
|
||||
def user_for_draco(request) -> dict | None:
|
||||
def user_for_draco(request, refuser_aucun: bool = False) -> dict | None:
|
||||
"""Utilisateur Flowdeck prouvé par le cookie draco_session, ou None.
|
||||
|
||||
Résolution : lien ``draco_links`` existant, sinon repli
|
||||
mono-utilisateur, sinon provisionnement JIT (HUB-15) — le
|
||||
pendant local est créé depuis les claims et lié au ``sub``.
|
||||
|
||||
HUB-19 (registre des rôles du Hub, contrat D3(a)) : avec
|
||||
``refuser_aucun=True`` — le pont d'ouverture de session
|
||||
``GET /auth/draco``, seul appelant qui crée une session locale —
|
||||
un rôle **connu** ``aucun`` dans le registre du Hub lève
|
||||
``RoleAucunRefuse`` **avant** toute résolution de compte et donc
|
||||
avant tout provisionnement JIT : aucun compte n'est créé pour un
|
||||
``sub`` refusé. Rôle inconnu (Hub injoignable/erreur) : repli,
|
||||
comportement actuel inchangé. Les autres appelants (recherche
|
||||
fédérée) gardent le défaut ``False`` : aucun refus nouveau.
|
||||
"""
|
||||
if not enabled():
|
||||
return None
|
||||
@@ -233,6 +259,12 @@ def user_for_draco(request) -> dict | None:
|
||||
sub = str(claims.get("sub", ""))
|
||||
if not sub:
|
||||
return None
|
||||
if refuser_aucun:
|
||||
from app.services import hub_roles
|
||||
|
||||
if hub_roles.role_pour(
|
||||
request.headers.get("cookie", ""), sub) == "aucun":
|
||||
raise RoleAucunRefuse(sub)
|
||||
with get_conn() as conn:
|
||||
user = _linked_user(conn, sub)
|
||||
if user is None:
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Rôles par application — consommation du registre du Hub (HUB-19).
|
||||
|
||||
Propagation du pilote ShaarIt (0.7.0, en production) à Flowdeck,
|
||||
sur le contrat D3(a) du plan Hub v2 : à l'ouverture de session
|
||||
draco, Flowdeck demande au Hub le niveau du porteur du cookie pour
|
||||
l'application ``flowdeck`` (``GET {hub}/api/roles/flowdeck``, en-tête
|
||||
Cookie complet de la requête relayé tel quel, ``Accept:
|
||||
application/json``, délai 2 s) et met la réponse en cache 60 s par
|
||||
``sub`` — patron du client Rust ``hub_roles.rs`` de ShaarIt et du
|
||||
cache de révocations de ``app.services.draco`` (horodatage posé
|
||||
**avant** l'appel : un échec ne déclenche pas de rafale, l'ancien
|
||||
cache reste en vigueur).
|
||||
|
||||
Réponse attendue (200) : ``{"app", "sub", "role", "explicite",
|
||||
"ttl_secondes": 60}``, rôle ∈ {``aucun``, ``utilisateur``,
|
||||
``administrateur``}.
|
||||
|
||||
**Repli obligatoire** : Hub injoignable, erreur (401/404/…), réseau,
|
||||
réponse illisible ou rôle hors ensemble → rôle **inconnu** (None),
|
||||
et l'appelant garde son comportement actuel. Le registre ne crée
|
||||
jamais un verrouillage nouveau.
|
||||
|
||||
L'URL du Hub est résolue comme la barre commune de Flowdeck
|
||||
(``app.services.draco_urls.hub_url()`` : variable explicite
|
||||
``HUB_URL`` / réglage, dérivation par l'hôte de la requête, puis par
|
||||
l'hôte sœur de l'agent, puis défaut officiel) — jamais d'URL dev
|
||||
figée.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Identifiant de Flowdeck dans le registre des applications du Hub.
|
||||
APP = "flowdeck"
|
||||
|
||||
#: TTL du cache, aligné sur ``ttl_secondes`` annoncé par le Hub et
|
||||
#: sur le cache de révocations draco.
|
||||
CACHE_TTL = 60 # secondes
|
||||
|
||||
ROLES_CONNUS = ("aucun", "utilisateur", "administrateur")
|
||||
|
||||
# Cache mémoire par sub : sub -> rôle connu (str) ou None (échec déjà
|
||||
# tenté dans la fenêtre — l'ancien résultat reste servi, voir
|
||||
# ``role_pour``). ``_cache_fetched_at`` est l'horodatage global du
|
||||
# dernier appel, posé avant l'appel (patron révocations).
|
||||
_cache: dict[str, str | None] = {}
|
||||
_cache_fetched_at: float = 0.0
|
||||
|
||||
|
||||
def _consulter_hub(cookie: str) -> str | None:
|
||||
"""Un appel au Hub ; retourne le rôle validé ou None (inconnu)."""
|
||||
from app.services import draco_urls
|
||||
|
||||
base = draco_urls.hub_url()
|
||||
url = f"{base}/api/roles/{APP}"
|
||||
try:
|
||||
import httpx
|
||||
|
||||
r = httpx.get(
|
||||
url,
|
||||
headers={"Cookie": cookie, "Accept": "application/json"},
|
||||
timeout=2.0,
|
||||
)
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
role = r.json().get("role")
|
||||
except Exception as exc: # réseau, JSON illisible… → repli
|
||||
logger.debug("Registre des rôles du Hub indisponible (%s): %s", url, exc)
|
||||
return None
|
||||
if role not in ROLES_CONNUS:
|
||||
return None
|
||||
return role
|
||||
|
||||
|
||||
def role_pour(cookie: str, sub: str) -> str | None:
|
||||
"""Niveau connu pour ce ``sub`` : ``aucun`` | ``utilisateur`` |
|
||||
``administrateur`` si le Hub a répondu, None si le rôle est
|
||||
inconnu (Hub injoignable/erreur) — repli = comportement actuel.
|
||||
|
||||
``cookie`` est l'en-tête Cookie complet de la requête, relayé tel
|
||||
quel au Hub ; ``sub`` est le sujet draco prouvé localement.
|
||||
Cache mémoire par ``sub``, TTL 60 s, horodatage posé avant
|
||||
l'appel : pendant la fenêtre qui suit un échec, l'ancien cache
|
||||
reste en vigueur (pas de rafale vers le Hub).
|
||||
"""
|
||||
global _cache_fetched_at
|
||||
if not sub:
|
||||
return None
|
||||
now = time.monotonic()
|
||||
if now - _cache_fetched_at < CACHE_TTL and sub in _cache:
|
||||
return _cache[sub]
|
||||
# Horodatage posé AVANT l'appel (patron révocations du pilote).
|
||||
ancien = _cache.get(sub)
|
||||
_cache_fetched_at = now
|
||||
resultat = _consulter_hub(cookie)
|
||||
if resultat is not None:
|
||||
_cache[sub] = resultat
|
||||
return resultat
|
||||
# Échec : l'ancien cache reste en vigueur.
|
||||
logger.debug(
|
||||
"Registre des rôles du Hub indisponible — comportement actuel conservé")
|
||||
return ancien
|
||||
|
||||
|
||||
def role_draco(request) -> str | None:
|
||||
"""Rôle du porteur draco de cette requête, ou None (inconnu).
|
||||
|
||||
None aussi quand il n'y a pas de session draco prouvée : pas de
|
||||
cookie ``draco_session``, jeton invalide/expiré/révoqué, ou SSO
|
||||
draco non configuré — l'appelant garde son comportement actuel.
|
||||
Le ``sub`` vient de la vérification locale du jeton (mêmes
|
||||
sémantiques que ``user_for_draco``, sans résolution de compte ni
|
||||
provisionnement).
|
||||
"""
|
||||
from app.services import draco
|
||||
|
||||
if not draco.enabled():
|
||||
return None
|
||||
claims = draco.verify_token(request.cookies.get(draco.COOKIE_NAME, ""))
|
||||
if not claims:
|
||||
return None
|
||||
jti = str(claims.get("jti", ""))
|
||||
if jti and draco.is_revoked(jti):
|
||||
return None
|
||||
sub = str(claims.get("sub", ""))
|
||||
if not sub:
|
||||
return None
|
||||
# En-tête Cookie complet de la requête, relayé tel quel au Hub.
|
||||
cookie = request.headers.get("cookie", "")
|
||||
return role_pour(cookie, sub)
|
||||
|
||||
|
||||
def admin_hub_ok(request) -> bool:
|
||||
"""Vrai si les surfaces d'administration restent ouvertes.
|
||||
|
||||
Session draco + rôle connu → exiger ``administrateur``. Pas de
|
||||
session draco (session locale par mot de passe, jeton d'API
|
||||
propre à Flowdeck, OAuth externe) ou rôle inconnu (Hub
|
||||
injoignable) : comportement actuel (True).
|
||||
"""
|
||||
from app.services import draco
|
||||
|
||||
if not draco.enabled():
|
||||
return True
|
||||
claims = draco.verify_token(request.cookies.get(draco.COOKIE_NAME, ""))
|
||||
if not claims:
|
||||
return True
|
||||
role = role_draco(request)
|
||||
if role is None:
|
||||
return True
|
||||
return role == "administrateur"
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Réglages d'accès de l'instance — inscription locale (fusion #146).
|
||||
|
||||
Patron des réglages existants (``llm_config``, ``sso_config``) : une
|
||||
table à ligne unique (``id = 1``) créée paresseusement, qui prime sur
|
||||
les valeurs de ``app.config.settings`` (variables d'environnement /
|
||||
``.env``), elles-mêmes aux défauts du code.
|
||||
|
||||
Réglage « inscription locale ouverte » :
|
||||
|
||||
- **fermée par défaut** (décision de Bruno, programme « compte
|
||||
partout ») : les comptes naissent dans le Hub Dracodev et entrent
|
||||
dans Flowdeck par la voie ObsiGate (SSO) / le provisionnement JIT ;
|
||||
le parcours public d'inscription locale est refusé proprement.
|
||||
- réversible à tout moment par un administrateur de l'instance
|
||||
(``GET``/``PUT /api/admin/reglages-acces``) ou en posant
|
||||
``INSCRIPTION_LOCALE_OUVERTE=true`` avant tout enregistrement en
|
||||
base.
|
||||
|
||||
Ce réglage ne touche **jamais** la connexion des comptes existants,
|
||||
locaux comme draco : il ne garde que la création de comptes locaux.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"get_reglages_acces",
|
||||
"set_reglages_acces",
|
||||
"inscription_locale_ouverte",
|
||||
"message_inscription_fermee",
|
||||
]
|
||||
|
||||
|
||||
def _assurer_table(conn) -> None:
|
||||
"""Crée la table des réglages d'accès si elle n'existe pas encore."""
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS reglages_acces (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
inscription_locale_ouverte INTEGER,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
def get_reglages_acces() -> dict:
|
||||
"""Réglages d'accès effectifs — la ligne en base prime sur la config."""
|
||||
reglages = {
|
||||
# Défaut du code et de la config : inscription fermée.
|
||||
"inscription_locale_ouverte": bool(
|
||||
getattr(settings, "inscription_locale_ouverte", False)),
|
||||
}
|
||||
try:
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
_assurer_table(conn)
|
||||
row = conn.execute(
|
||||
"SELECT inscription_locale_ouverte FROM reglages_acces"
|
||||
" WHERE id=1"
|
||||
).fetchone()
|
||||
except Exception: # noqa: BLE001 — base pas encore prête → config seule
|
||||
return reglages
|
||||
if row is not None and row["inscription_locale_ouverte"] is not None:
|
||||
reglages["inscription_locale_ouverte"] = bool(
|
||||
row["inscription_locale_ouverte"])
|
||||
return reglages
|
||||
|
||||
|
||||
def set_reglages_acces(*, inscription_locale_ouverte: bool | None = None) -> dict:
|
||||
"""Enregistre les réglages d'accès (ligne unique) et les renvoie."""
|
||||
from app.db import get_conn
|
||||
|
||||
actuels = get_reglages_acces()
|
||||
if inscription_locale_ouverte is not None:
|
||||
actuels["inscription_locale_ouverte"] = bool(inscription_locale_ouverte)
|
||||
with get_conn() as conn:
|
||||
_assurer_table(conn)
|
||||
conn.execute(
|
||||
"INSERT INTO reglages_acces (id, inscription_locale_ouverte,"
|
||||
" updated_at) VALUES (1, ?, CURRENT_TIMESTAMP)"
|
||||
" ON CONFLICT(id) DO UPDATE SET"
|
||||
" inscription_locale_ouverte=excluded.inscription_locale_ouverte,"
|
||||
" updated_at=CURRENT_TIMESTAMP",
|
||||
(1 if actuels["inscription_locale_ouverte"] else 0,),
|
||||
)
|
||||
conn.commit()
|
||||
return actuels
|
||||
|
||||
|
||||
def inscription_locale_ouverte() -> bool:
|
||||
"""Vrai si l'inscription publique locale est ouverte sur l'instance."""
|
||||
return bool(get_reglages_acces()["inscription_locale_ouverte"])
|
||||
|
||||
|
||||
def message_inscription_fermee() -> str:
|
||||
"""Message affiché quand l'inscription locale est fermée (#146).
|
||||
|
||||
Nomme le Hub Dracodev — là où les comptes se créent — avec son URL
|
||||
dérivée de l'hôte comme les autres liens de la suite
|
||||
(``draco_urls.hub_url()``, jamais un nom figé).
|
||||
"""
|
||||
from app.services import draco_urls
|
||||
|
||||
return (
|
||||
"L'inscription locale est fermée sur cette instance : "
|
||||
"les comptes se créent dans le Hub Dracodev "
|
||||
f"({draco_urls.hub_url()}), puis se connectent ici avec le "
|
||||
"bouton « Login with ObsiGate (SSO) »."
|
||||
)
|
||||
@@ -113,6 +113,14 @@
|
||||
dérivation par l'agent, défaut officiel) alimente les
|
||||
liens du menu compte (Télécharger, À Propos, Aides,
|
||||
Profil) via urls.hub. */
|
||||
/* Barre v0.6.0 (fusion #142) : JS/CSS re-vendangés verbatim
|
||||
— le menu compte gagne l'entrée « Administration »
|
||||
(lien Hub <hub>/admin) à visibilité conditionnelle.
|
||||
Flowdeck ne passe PAS le drapeau admin : il ne connaît
|
||||
que le rôle du registre du Hub pour sa propre app
|
||||
(consulté au pont draco), pas le rôle global de suite
|
||||
(rôle du compte chez ObsiGate) — à défaut de certitude,
|
||||
l'entrée reste masquée ici, comme le contrat l'exige. */
|
||||
urls: { hub: "{{ draco_urls().hub }}/" },
|
||||
domain: (function () { try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; } })(),
|
||||
counts: {},
|
||||
@@ -132,6 +140,20 @@
|
||||
return;
|
||||
}
|
||||
if (act === "palette") { location.href = "/recherche"; return; }
|
||||
if (act === "deconnexion") {
|
||||
/* Fusion #141 : détruire d'abord la session locale
|
||||
Flowdeck (GET /auth/logout), puis naviguer vers la
|
||||
déconnexion globale de l'émettrice (ObsiGate,
|
||||
GET /sso/logout) avec retour ici — le jti draco y
|
||||
est révoqué et le cookie effacé sur le domaine
|
||||
parent : la session de la suite entière se termine. */
|
||||
var retourFd = encodeURIComponent(location.origin + "/");
|
||||
var cibleFd = "{{ draco_urls().obsigate }}/sso/logout?return_to=" + retourFd;
|
||||
fetch("/auth/logout", { credentials: "same-origin" })
|
||||
.catch(function () { })
|
||||
.finally(function () { location.href = cibleFd; });
|
||||
return;
|
||||
}
|
||||
/* Correctif fusion #70 : action "menu" supprimée avec le bouton trois points (console agent dans le menu de la marque). Nonce CSP du script inchangé. */
|
||||
if (act === "spark") {
|
||||
if (window.DRACO_PANNEAU) { window.DRACO_PANNEAU.toggle(); }
|
||||
|
||||
@@ -179,7 +179,7 @@
|
||||
</a>
|
||||
<div class="landing-nav-actions">
|
||||
<a href="/auth/login?provider=local" class="btn btn-secondary">Log in</a>
|
||||
<a href="/auth/register" class="btn btn-primary">Get started free</a>
|
||||
{% if inscription_ouverte %}<a href="/auth/register" class="btn btn-primary">Get started free</a>{% else %}<a href="{{ hub_url }}" class="btn btn-primary">Créer un compte dans le Hub Dracodev</a>{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -191,7 +191,7 @@
|
||||
All on your own server.
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<a href="/auth/register" class="btn btn-primary">Get started — it's free</a>
|
||||
{% if inscription_ouverte %}<a href="/auth/register" class="btn btn-primary">Get started — it's free</a>{% else %}<a href="{{ hub_url }}" class="btn btn-primary">Créer un compte dans le Hub Dracodev</a>{% endif %}
|
||||
<a href="/auth/login?provider=local" class="btn btn-secondary">I already have an account</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -101,6 +101,11 @@
|
||||
text-decoration: none; cursor: pointer;
|
||||
}
|
||||
.draco-menu-item:hover, .draco-menu-item:focus-visible { background: var(--surface-2); }
|
||||
/* Item conditionnel masqué (v0.6.0 : « Administration » tant que
|
||||
l'hôte n'a pas affirmé le statut d'administrateur) — le
|
||||
display:flex ci-dessus écraserait l'attribut hidden sans cette
|
||||
règle, même patron que .draco-compte-menu[hidden]. */
|
||||
.draco-menu-item[hidden] { display: none; }
|
||||
/* Icônes monoline du menu compte (v0.4.0) : appendues après le
|
||||
libellé dans le DOM, replacées à gauche par order: -1. */
|
||||
.draco-menu-icone {
|
||||
|
||||
Vendored
+67
@@ -42,6 +42,22 @@
|
||||
* l'ouverture par le mécanisme data-theme des hôtes, avec cache
|
||||
* local court et repli intégral sur le comportement actuel de
|
||||
* l'hôte si le Hub ne répond pas. Voir « Thème commun » plus bas.
|
||||
* v0.6.0 (demande de Bruno, 2026-11-06) : le menu compte gagne un
|
||||
* cinquième lien Hub, « Administration » → <hub>/admin (le Hub y
|
||||
* redirige vers /admin/utilisateurs), placé après le séparateur,
|
||||
* avant « Déconnexion », avec son icône monoline (curseurs de
|
||||
* réglage). AFFICHAGE CONDITIONNEL : l'entrée n'apparaît que si
|
||||
* l'hôte AFFIRME que le compte courant est administrateur de
|
||||
* suite — cfg.admin === true à la configuration, ou
|
||||
* DRACO_BAR.setAdmin(true) quand l'hôte l'apprend après coup
|
||||
* (patron setUrls). L'hôte ne passe le drapeau que s'il l'a établi
|
||||
* depuis SA source d'autorité (rôle global du compte chez
|
||||
* ObsiGate, niveau du registre des rôles du Hub pour son app…) ;
|
||||
* à défaut de certitude, pas de drapeau et l'entrée reste masquée.
|
||||
* La barre ne devine jamais : le jeton draco ne porte aucun rôle
|
||||
* (claims minimaux, par design). La vraie protection reste la
|
||||
* garde serveur du Hub (/admin/*), pas ce masquage d'affichage.
|
||||
* Voir le README, « Administration » — contrat de visibilité.
|
||||
*
|
||||
* Aucun gestionnaire inline : un seul écouteur délégué sur le document
|
||||
* (patron validé par le prototype — les onclick du HTML généré sont
|
||||
@@ -54,6 +70,11 @@
|
||||
* counts: { tiktak: 3, kanbante: 3, alertes: 3, spark: 0 },
|
||||
* user: "Bruno", // nom affiché (v0.1.6) ;
|
||||
* // ou { display_name: "Bruno", username: "bruno" }
|
||||
* admin: true, // (v0.6.0, optionnel)
|
||||
* // administrateur de suite ÉTABLI par l'hôte : démasque
|
||||
* // l'entrée « Administration » du menu compte. Absent ou
|
||||
* // non-strictement-true → entrée masquée. Peut aussi être
|
||||
* // posé après coup via DRACO_BAR.setAdmin(true).
|
||||
* urls: { // bases des applications
|
||||
* obsigate: "https://og.dracodev.net/",
|
||||
* flowdeck: "https://flowdeck.dracodev.net/",
|
||||
@@ -653,6 +674,18 @@
|
||||
["path", { d: "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" }],
|
||||
["circle", { cx: "12", cy: "7", r: "4" }]
|
||||
],
|
||||
/* Curseurs de réglage (trois axes, trois boutons) — v0.6.0. */
|
||||
"admin": [
|
||||
["line", { x1: "4", y1: "21", x2: "4", y2: "14" }],
|
||||
["line", { x1: "4", y1: "10", x2: "4", y2: "3" }],
|
||||
["line", { x1: "12", y1: "21", x2: "12", y2: "12" }],
|
||||
["line", { x1: "12", y1: "8", x2: "12", y2: "3" }],
|
||||
["line", { x1: "20", y1: "21", x2: "20", y2: "16" }],
|
||||
["line", { x1: "20", y1: "12", x2: "20", y2: "3" }],
|
||||
["line", { x1: "1", y1: "14", x2: "7", y2: "14" }],
|
||||
["line", { x1: "9", y1: "8", x2: "15", y2: "8" }],
|
||||
["line", { x1: "17", y1: "16", x2: "23", y2: "16" }]
|
||||
],
|
||||
/* Flèche sortant d'un cadre (porte). */
|
||||
"deconnexion": [
|
||||
["path", { d: "M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" }],
|
||||
@@ -687,6 +720,15 @@
|
||||
return span;
|
||||
}
|
||||
|
||||
/* Visibilité de l'entrée « Administration » (v0.6.0) : vraie
|
||||
* seulement si l'hôte l'a AFFIRMÉE — cfg.admin === true à la
|
||||
* configuration, ou DRACO_BAR.setAdmin(true) après coup. La
|
||||
* barre ne déduit jamais ce statut (le jeton draco ne porte
|
||||
* aucun rôle) ; à défaut, l'entrée reste masquée et la garde
|
||||
* serveur du Hub reste la vraie protection. */
|
||||
var compteAdmin = cfg.admin === true;
|
||||
var adminItem = null;
|
||||
|
||||
/* Menu compte (v0.1.6 ; v0.3.0 pour les liens) — en-tête
|
||||
* d'identité, puis les liens du Hub (Télécharger, À Propos,
|
||||
* Aides, Profil — vrais <a href>, même patron que le menu des
|
||||
@@ -719,6 +761,22 @@
|
||||
sep.className = "draco-menu-sep";
|
||||
sep.setAttribute("aria-hidden", "true");
|
||||
compteMenu.appendChild(sep);
|
||||
/* « Administration » (v0.6.0) : cinquième lien Hub
|
||||
* (<hub>/admin, où le Hub redirige vers /admin/utilisateurs).
|
||||
* data-hub-link="admin" : son href est resynchronisé par
|
||||
* refreshCompteHrefs / setUrls comme les quatre autres liens.
|
||||
* Toujours construit ; masqué tant que l'hôte n'a pas affirmé
|
||||
* le statut d'administrateur (compteAdmin). */
|
||||
var adm = document.createElement("a");
|
||||
adm.className = "draco-menu-item";
|
||||
adm.href = hubHref("admin");
|
||||
adm.setAttribute("data-hub-link", "admin");
|
||||
adm.setAttribute("role", "menuitem");
|
||||
adm.textContent = "Administration";
|
||||
adm.appendChild(menuIcone("admin"));
|
||||
adm.hidden = !compteAdmin;
|
||||
compteMenu.appendChild(adm);
|
||||
adminItem = adm;
|
||||
var b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "draco-menu-item";
|
||||
@@ -734,6 +792,7 @@
|
||||
var head = compteMenu.querySelector(".draco-compte-nom");
|
||||
var name = displayName();
|
||||
if (head) head.textContent = name || "Compte";
|
||||
if (adminItem) adminItem.hidden = !compteAdmin;
|
||||
refreshCompteHrefs();
|
||||
}
|
||||
function toggleCompteMenu(force) {
|
||||
@@ -835,6 +894,14 @@
|
||||
window.DRACO_BAR.urls = resolvedUrls;
|
||||
applyUrls(resolvedUrls);
|
||||
refreshCompteHrefs();
|
||||
},
|
||||
/* Administration (v0.6.0) : l'hôte pose le drapeau quand il a
|
||||
* ÉTABLI que le compte courant est administrateur de suite
|
||||
* (sa source d'autorité — voir l'en-tête et le README). Toute
|
||||
* valeur autre que le booléen true masque l'entrée. */
|
||||
setAdmin: function (v) {
|
||||
compteAdmin = v === true;
|
||||
if (adminItem) adminItem.hidden = !compteAdmin;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -127,6 +127,14 @@ def client():
|
||||
from app.main import app
|
||||
init_db()
|
||||
|
||||
# Fusion #146 : l'inscription locale est fermée par défaut en
|
||||
# production (les comptes naissent au Hub). L'environnement de
|
||||
# test l'ouvre explicitement pour les suites historiques qui
|
||||
# créent leurs comptes par /auth/register ; les tests du réglage
|
||||
# (tests/test_reglages_acces.py) la ferment eux-mêmes.
|
||||
from app.services import reglages_acces
|
||||
reglages_acces.set_reglages_acces(inscription_locale_ouverte=True)
|
||||
|
||||
yield login_test_client(TestClient(app))
|
||||
|
||||
# Cleanup
|
||||
|
||||
@@ -31,6 +31,12 @@ def client():
|
||||
from app.main import app
|
||||
init_db()
|
||||
|
||||
# Fusion #146 : inscription fermée par défaut en production —
|
||||
# cette suite historique crée ses comptes par /auth/register,
|
||||
# elle ouvre donc le réglage explicitement (comme conftest).
|
||||
from app.services import reglages_acces
|
||||
reglages_acces.set_reglages_acces(inscription_locale_ouverte=True)
|
||||
|
||||
# A default user id=1 so tests that reference the implicit user (favorites,
|
||||
# workspace ownership) satisfy foreign keys without relying on leaked state.
|
||||
from app.db import get_conn
|
||||
|
||||
@@ -33,6 +33,35 @@ def test_aucun_texte_chantier_rendu():
|
||||
assert rendu.lstrip().startswith("<link")
|
||||
|
||||
|
||||
def test_barre_deconnexion_branchee_globale():
|
||||
"""Déconnexion du menu compte (fusion #141) : le gabarit branche
|
||||
l'action « deconnexion » — destruction de la session locale
|
||||
(/auth/logout) puis navigation vers la déconnexion globale de
|
||||
l'émettrice (GET /sso/logout chez ObsiGate, retour ici). Avant le
|
||||
correctif, aucune branche ne traitait l'action : clic sans effet."""
|
||||
gabarit = (ROOT / "app/templates/_draco_bar.html").read_text()
|
||||
assert 'act === "deconnexion"' in gabarit
|
||||
assert 'fetch("/auth/logout"' in gabarit
|
||||
assert '{{ draco_urls().obsigate }}/sso/logout?return_to=' in gabarit
|
||||
|
||||
|
||||
def test_barre_v060_entree_administration_vendangee():
|
||||
"""Barre v0.6.0 (fusion #142) : JS/CSS vendangés portent l'entrée
|
||||
« Administration » du menu compte (lien Hub data-hub-link
|
||||
« admin », setAdmin, règle CSS [hidden]) ; le gabarit documente
|
||||
pourquoi Flowdeck ne passe pas le drapeau (pas de rôle global
|
||||
connu) — aucun « admin: true » dans la configuration servie."""
|
||||
js = (ROOT / "static/js/vendor/draco-barre.js").read_text()
|
||||
assert 'setAttribute("data-hub-link", "admin")' in js
|
||||
assert '"Administration"' in js
|
||||
assert "setAdmin" in js
|
||||
css = (ROOT / "static/css/draco-barre.css").read_text()
|
||||
assert ".draco-menu-item[hidden]" in css
|
||||
gabarit = (ROOT / "app/templates/_draco_bar.html").read_text()
|
||||
assert "v0.6.0" in gabarit
|
||||
assert "admin: true" not in gabarit
|
||||
|
||||
|
||||
def test_barre_v020_onglet_shaarit_et_garde_data_theme():
|
||||
"""Barre v0.2.0 (commons @70c837a) : onglet ShaarIt entre Kanbante
|
||||
et l'onglet agent, logo fourni tel quel, CSS vendangé verbatim
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
"""HUB-19 — propagation des rôles du Hub à Flowdeck (suite du pilote
|
||||
ShaarIt 0.7.0) : client ``app.services.hub_roles`` et ses effets.
|
||||
|
||||
Contrat D3(a) : à l'ouverture de session draco, consultation de
|
||||
``GET {hub}/api/roles/flowdeck`` (cookie complet relayé, délai 2 s),
|
||||
cache mémoire par ``sub`` TTL 60 s, horodatage posé avant l'appel.
|
||||
Toute erreur = rôle inconnu → comportement actuel conservé.
|
||||
|
||||
Effets vérifiés ici :
|
||||
- rôle connu ``aucun`` → 403 en français au pont ``GET /auth/draco``,
|
||||
avant la création de la session locale et avant le provisionnement
|
||||
JIT HUB-15 (aucun compte créé pour un ``sub`` refusé) ;
|
||||
- session locale préexistante exemptée (accès de secours) ;
|
||||
- surfaces d'administration d'instance (``/api/admin/*``) : session
|
||||
draco + rôle connu → ``administrateur`` exigé ;
|
||||
- rôle inconnu (Hub injoignable/erreur/rôle hors ensemble) → repli
|
||||
intégral.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from conftest import anon
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services import draco, hub_roles
|
||||
|
||||
SECRET = "secret-partage-de-test"
|
||||
|
||||
|
||||
def _jeton(sub="admin-1", secret=SECRET, exp=None, jti="jti-hub19", name="Bruno"):
|
||||
def b64(d: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(d).rstrip(b"=").decode()
|
||||
|
||||
header = b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
|
||||
payload = b64(json.dumps({
|
||||
"sub": sub, "name": name, "iat": int(time.time()),
|
||||
"exp": exp if exp is not None else int(time.time()) + 3600,
|
||||
"jti": jti,
|
||||
}).encode())
|
||||
sig = hmac.new(secret.encode(), f"{header}.{payload}".encode(),
|
||||
hashlib.sha256).digest()
|
||||
return f"{header}.{payload}.{b64(sig)}"
|
||||
|
||||
|
||||
def _requete(cookie_complet="", draco_token=None):
|
||||
cookies = {}
|
||||
headers = {}
|
||||
if draco_token is not None:
|
||||
cookies["draco_session"] = draco_token
|
||||
complet = f"draco_session={draco_token}"
|
||||
headers["cookie"] = complet if not cookie_complet else cookie_complet
|
||||
elif cookie_complet:
|
||||
headers["cookie"] = cookie_complet
|
||||
return SimpleNamespace(cookies=cookies, headers=headers)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _caches_propres(monkeypatch):
|
||||
monkeypatch.setenv("DRACO_SESSION_SECRET", SECRET)
|
||||
monkeypatch.setattr(draco, "_refresh_revocations", lambda: None)
|
||||
draco._revoked_cache.clear()
|
||||
hub_roles._cache.clear()
|
||||
hub_roles._cache_fetched_at = 0.0
|
||||
yield
|
||||
draco._revoked_cache.clear()
|
||||
hub_roles._cache.clear()
|
||||
hub_roles._cache_fetched_at = 0.0
|
||||
|
||||
|
||||
def _pose_role(monkeypatch, role, compteur=None):
|
||||
"""Simule la réponse du Hub pour ``role_pour`` (appel réseau évité)."""
|
||||
def faux_consulter(cookie):
|
||||
if compteur is not None:
|
||||
compteur.append(cookie)
|
||||
return role
|
||||
monkeypatch.setattr(hub_roles, "_consulter_hub", faux_consulter)
|
||||
|
||||
|
||||
# ── Client : cache par sub, TTL, horodatage avant l'appel ───────────
|
||||
|
||||
def test_role_connu_mis_en_cache_par_sub(monkeypatch):
|
||||
appels = []
|
||||
_pose_role(monkeypatch, "utilisateur", appels)
|
||||
assert hub_roles.role_pour("cookie-a", "sub-1") == "utilisateur"
|
||||
assert hub_roles.role_pour("cookie-a", "sub-1") == "utilisateur"
|
||||
assert len(appels) == 1 # 2e lecture : cache
|
||||
assert hub_roles.role_pour("cookie-b", "sub-2") == "utilisateur"
|
||||
assert len(appels) == 2 # autre sub : nouvel appel
|
||||
|
||||
|
||||
def test_echec_ancien_cache_conserve_pas_de_rafale(monkeypatch):
|
||||
appels = []
|
||||
_pose_role(monkeypatch, "utilisateur", appels)
|
||||
assert hub_roles.role_pour("c", "sub-1") == "utilisateur"
|
||||
# Fenêtre expirée, Hub désormais en échec : l'ancien cache reste.
|
||||
hub_roles._cache_fetched_at = time.monotonic() - 61
|
||||
_pose_role(monkeypatch, None, appels)
|
||||
assert hub_roles.role_pour("c", "sub-1") == "utilisateur"
|
||||
assert len(appels) == 2
|
||||
# Pas de rafale : dans la fenêtre qui suit l'échec, aucun rappel.
|
||||
assert hub_roles.role_pour("c", "sub-1") == "utilisateur"
|
||||
assert len(appels) == 2
|
||||
|
||||
|
||||
def test_echec_sans_cache_role_inconnu(monkeypatch):
|
||||
# Fidèle au pilote : sans entrée en cache pour ce sub, l'échec
|
||||
# rend « inconnu » et la demande suivante retente (la protection
|
||||
# anti-rafale de l'horodatage posé avant l'appel ne sert que les
|
||||
# subs déjà en cache — test précédent).
|
||||
appels = []
|
||||
_pose_role(monkeypatch, None, appels)
|
||||
assert hub_roles.role_pour("c", "sub-x") is None
|
||||
assert hub_roles.role_pour("c", "sub-x") is None
|
||||
assert len(appels) == 2
|
||||
|
||||
|
||||
def test_role_sans_sub_inconnu(monkeypatch):
|
||||
_pose_role(monkeypatch, "aucun")
|
||||
assert hub_roles.role_pour("c", "") is None
|
||||
|
||||
|
||||
# ── Client : validation de la réponse du Hub ────────────────────────
|
||||
|
||||
class _Reponse:
|
||||
def __init__(self, status_code, payload=None):
|
||||
self.status_code = status_code
|
||||
self._payload = payload
|
||||
|
||||
def json(self):
|
||||
if isinstance(self._payload, Exception):
|
||||
raise self._payload
|
||||
return self._payload
|
||||
|
||||
|
||||
def _pose_http(monkeypatch, reponse_ou_exception, appels=None):
|
||||
import httpx
|
||||
|
||||
from app.services import draco_urls
|
||||
monkeypatch.setattr(draco_urls, "hub_url", lambda: "https://hub.example")
|
||||
|
||||
def faux_get(url, headers=None, timeout=None):
|
||||
if appels is not None:
|
||||
appels.append((url, headers, timeout))
|
||||
if isinstance(reponse_ou_exception, Exception):
|
||||
raise reponse_ou_exception
|
||||
return reponse_ou_exception
|
||||
|
||||
monkeypatch.setattr(httpx, "get", faux_get)
|
||||
|
||||
|
||||
def test_consultation_contrat_exact(monkeypatch):
|
||||
appels = []
|
||||
_pose_http(monkeypatch, _Reponse(200, {
|
||||
"app": "flowdeck", "sub": "sub-1", "role": "administrateur",
|
||||
"explicite": True, "ttl_secondes": 60}), appels)
|
||||
cookie = "draco_session=jeton; autre=1"
|
||||
assert hub_roles._consulter_hub(cookie) == "administrateur"
|
||||
url, headers, timeout = appels[0]
|
||||
assert url == "https://hub.example/api/roles/flowdeck"
|
||||
assert headers["Cookie"] == cookie # cookie complet relayé
|
||||
assert headers["Accept"] == "application/json"
|
||||
assert timeout == 2.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reponse", [
|
||||
_Reponse(401, {}), _Reponse(404, {}), _Reponse(500, {}),
|
||||
_Reponse(200, {"role": "superadmin"}), # hors ensemble
|
||||
_Reponse(200, {"role": None}),
|
||||
_Reponse(200, ValueError("json illisible")),
|
||||
ConnectionError("hub injoignable"),
|
||||
])
|
||||
def test_consultation_erreurs_role_inconnu(monkeypatch, reponse):
|
||||
_pose_http(monkeypatch, reponse)
|
||||
assert hub_roles._consulter_hub("c") is None
|
||||
|
||||
|
||||
# ── role_draco / admin_hub_ok ───────────────────────────────────────
|
||||
|
||||
def test_role_draco_sans_cookie_inconnu():
|
||||
assert hub_roles.role_draco(_requete()) is None
|
||||
|
||||
|
||||
def test_role_draco_avec_cookie(monkeypatch):
|
||||
_pose_role(monkeypatch, "administrateur")
|
||||
assert hub_roles.role_draco(_requete(draco_token=_jeton())) == "administrateur"
|
||||
|
||||
|
||||
def test_admin_hub_ok_sans_draco_comportement_actuel():
|
||||
assert hub_roles.admin_hub_ok(_requete()) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role,attendu", [
|
||||
("administrateur", True), ("utilisateur", False), ("aucun", False),
|
||||
(None, True), # inconnu → repli
|
||||
])
|
||||
def test_admin_hub_ok_selon_role(monkeypatch, role, attendu):
|
||||
_pose_role(monkeypatch, role)
|
||||
assert hub_roles.admin_hub_ok(_requete(draco_token=_jeton())) is attendu
|
||||
|
||||
|
||||
# ── Intégration : pont /auth/draco ──────────────────────────────────
|
||||
|
||||
def _compte_utilisateurs():
|
||||
with get_conn() as conn:
|
||||
return conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
|
||||
|
||||
def _ajoute_deuxieme_utilisateur():
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO users (login, email, password_hash, full_name, is_active) "
|
||||
"VALUES ('deuxieme', '[email protected]', 'x', 'Deuxième', 1)")
|
||||
conn.commit()
|
||||
|
||||
|
||||
def test_pont_refus_aucun_403_en_francais(client, monkeypatch):
|
||||
c = anon(client)
|
||||
_pose_role(monkeypatch, "aucun")
|
||||
c.cookies.set("draco_session", _jeton(sub="admin-1"))
|
||||
r = c.get("/auth/draco", follow_redirects=False)
|
||||
assert r.status_code == 403
|
||||
assert "registre des rôles" in r.text and "Hub" in r.text
|
||||
assert "« aucun »" in r.text
|
||||
assert "flowdeck_session" not in r.headers.get("set-cookie", "")
|
||||
assert c.get("/auth/user").json()["authenticated"] is False
|
||||
|
||||
|
||||
def test_pont_refus_aucun_avant_provisionnement_jit(client, monkeypatch):
|
||||
c = anon(client)
|
||||
_ajoute_deuxieme_utilisateur()
|
||||
avant = _compte_utilisateurs()
|
||||
_pose_role(monkeypatch, "aucun")
|
||||
c.cookies.set("draco_session", _jeton(sub="og-refuse", name="Refusée"))
|
||||
r = c.get("/auth/draco", follow_redirects=False)
|
||||
assert r.status_code == 403
|
||||
# Ordre vérifié : refus avant JIT — aucun compte, aucun lien créés.
|
||||
assert _compte_utilisateurs() == avant
|
||||
with get_conn() as conn:
|
||||
assert conn.execute(
|
||||
"SELECT COUNT(*) FROM draco_links WHERE sub = 'og-refuse'"
|
||||
).fetchone()[0] == 0
|
||||
|
||||
|
||||
def test_pont_role_utilisateur_jit_inchange(client, monkeypatch):
|
||||
c = anon(client)
|
||||
_ajoute_deuxieme_utilisateur()
|
||||
avant = _compte_utilisateurs()
|
||||
_pose_role(monkeypatch, "utilisateur")
|
||||
c.cookies.set("draco_session", _jeton(sub="og-nouveau", name="Nouvelle"))
|
||||
r = c.get("/auth/draco", follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
assert r.headers["location"] == "/workspaces"
|
||||
assert _compte_utilisateurs() == avant + 1 # JIT HUB-15 intact
|
||||
|
||||
|
||||
def test_pont_role_inconnu_repli_comportement_actuel(client, monkeypatch):
|
||||
c = anon(client)
|
||||
_pose_role(monkeypatch, None) # Hub injoignable
|
||||
c.cookies.set("draco_session", _jeton(sub="admin-1"))
|
||||
r = c.get("/auth/draco", follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
assert r.headers["location"] == "/workspaces"
|
||||
|
||||
|
||||
def test_pont_session_locale_preexistante_exemptee(client, monkeypatch):
|
||||
# Le client de la fixture porte déjà une session locale (admin de
|
||||
# test) : patron du pilote — l'accès de secours n'est pas refusé.
|
||||
_pose_role(monkeypatch, "aucun")
|
||||
client.cookies.set("draco_session", _jeton(sub="admin-1"))
|
||||
r = client.get("/auth/draco", follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
assert r.headers["location"] == "/workspaces"
|
||||
|
||||
|
||||
# ── Intégration : surfaces d'administration (/api/admin/*) ──────────
|
||||
|
||||
def test_admin_instance_role_utilisateur_refuse(client, monkeypatch):
|
||||
_pose_role(monkeypatch, "utilisateur")
|
||||
client.cookies.set("draco_session", _jeton(sub="admin-1"))
|
||||
r = client.get("/api/admin/users")
|
||||
assert r.status_code == 403
|
||||
assert "registre" in r.text and "Hub" in r.text
|
||||
|
||||
|
||||
def test_admin_instance_role_administrateur_accepte(client, monkeypatch):
|
||||
_pose_role(monkeypatch, "administrateur")
|
||||
client.cookies.set("draco_session", _jeton(sub="admin-1"))
|
||||
r = client.get("/api/admin/users")
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_admin_instance_role_inconnu_repli(client, monkeypatch):
|
||||
_pose_role(monkeypatch, None)
|
||||
client.cookies.set("draco_session", _jeton(sub="admin-1"))
|
||||
r = client.get("/api/admin/users")
|
||||
assert r.status_code == 200 # comportement actuel
|
||||
|
||||
|
||||
def test_admin_instance_sans_draco_inchange(client, monkeypatch):
|
||||
_pose_role(monkeypatch, "aucun") # jamais consulté sans draco
|
||||
r = client.get("/api/admin/users")
|
||||
assert r.status_code == 200
|
||||
@@ -0,0 +1,177 @@
|
||||
"""FlowDeck — réglages d'accès : inscription locale (fusion #146, #144).
|
||||
|
||||
Contrat de la phase 1 du programme « compte partout » :
|
||||
|
||||
- inscription locale **fermée par défaut** (décision de Bruno) :
|
||||
le parcours public d'inscription est refusé proprement et nomme
|
||||
le Hub Dracodev, là où les comptes se créent ;
|
||||
- réglage **réversible par un administrateur** (API
|
||||
``/api/admin/reglages-acces``) : ouverte, l'inscription redevient
|
||||
exactement celle d'aujourd'hui ;
|
||||
- la connexion des comptes existants (locaux et draco) n'est
|
||||
**jamais** affectée par le réglage ;
|
||||
- #144 : l'échec du formulaire de connexion locale suggère la voie
|
||||
ObsiGate (SSO).
|
||||
|
||||
Le harnais de test ouvre l'inscription pour les suites historiques
|
||||
(voir conftest) ; chaque test pose donc explicitement l'état qu'il
|
||||
vérifie via le service des réglages.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from conftest import anon_csrf
|
||||
|
||||
from app.services import reglages_acces
|
||||
|
||||
|
||||
def _fermer():
|
||||
reglages_acces.set_reglages_acces(inscription_locale_ouverte=False)
|
||||
|
||||
|
||||
def _ouvrir():
|
||||
reglages_acces.set_reglages_acces(inscription_locale_ouverte=True)
|
||||
|
||||
|
||||
def _creer_compte_local(login: str, mot_de_passe: str, admin: int = 0) -> None:
|
||||
from app.db import get_conn
|
||||
from app.password_utils import hash_password
|
||||
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO users (login, full_name, email,"
|
||||
" password_hash, is_admin) VALUES (?, ?, ?, ?, ?)",
|
||||
(login, login, f"{login}@test.dev",
|
||||
hash_password(mot_de_passe), admin),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
# ── Réglage fermé : inscription impossible + message ────────────────
|
||||
|
||||
def test_inscription_fermee_par_defaut_dans_le_code():
|
||||
"""Le défaut du code (hors toute ligne en base) est « fermée »."""
|
||||
from app.config import settings
|
||||
|
||||
assert settings.inscription_locale_ouverte is False
|
||||
|
||||
|
||||
def test_inscription_fermee_post_refuse_avec_message_hub(client):
|
||||
_fermer()
|
||||
r = client.post("/auth/register", json={
|
||||
"email": "[email protected]", "password": "secret123",
|
||||
"name": "Visiteur"})
|
||||
assert r.status_code == 403
|
||||
erreur = r.json()["error"]
|
||||
assert "Hub Dracodev" in erreur
|
||||
# Lien dérivé vers le Hub fourni à l'écran comme aux API.
|
||||
assert r.json()["hub_url"]
|
||||
# Aucun compte n'a été créé.
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
assert conn.execute(
|
||||
"SELECT COUNT(*) FROM users WHERE login='[email protected]'"
|
||||
).fetchone()[0] == 0
|
||||
|
||||
|
||||
def test_inscription_fermee_page_refus_propre(client):
|
||||
_fermer()
|
||||
r = anon_csrf(client).get("/auth/register", follow_redirects=False)
|
||||
assert r.status_code == 403
|
||||
assert "Hub Dracodev" in r.text
|
||||
assert "les comptes se créent" in r.text
|
||||
|
||||
|
||||
def test_inscription_fermee_page_connexion_sans_onglet_register(client):
|
||||
_fermer()
|
||||
r = anon_csrf(client).get("/auth/login?provider=local")
|
||||
assert r.status_code == 200
|
||||
assert 'id="tab-register"' not in r.text
|
||||
# L'avis nomme le Hub et la voie SSO reste visible.
|
||||
assert "Hub Dracodev" in r.text
|
||||
assert "Login with ObsiGate (SSO)" in r.text
|
||||
|
||||
|
||||
# ── Réglage ouvert : inscription comme aujourd'hui ──────────────────
|
||||
|
||||
def test_inscription_ouverte_post_comme_avant(client):
|
||||
_ouvrir()
|
||||
r = client.post("/auth/register", json={
|
||||
"email": "[email protected]", "password": "secret123",
|
||||
"name": "Nouveau"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "ok"
|
||||
|
||||
|
||||
def test_inscription_ouverte_page_register_affichee(client):
|
||||
_ouvrir()
|
||||
r = anon_csrf(client).get("/auth/register", follow_redirects=False)
|
||||
assert r.status_code == 200
|
||||
assert 'id="tab-register"' in r.text
|
||||
|
||||
|
||||
# ── Connexion des comptes existants : jamais affectée ───────────────
|
||||
|
||||
def test_connexion_compte_local_existant_inscription_fermee(client):
|
||||
_creer_compte_local("ancien", "secret123")
|
||||
_fermer()
|
||||
r = anon_csrf(client).post("/auth/local-login", json={
|
||||
"email": "ancien", "password": "secret123"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "ok"
|
||||
|
||||
|
||||
def test_connexion_compte_draco_sans_mot_de_passe_inchangee(client):
|
||||
"""Un compte draco (sans mot de passe local) reste refusé en local
|
||||
de la même façon, réglage ouvert ou fermé : le réglage ne change
|
||||
rien à l'authentification."""
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO users (login, full_name, email,"
|
||||
" password_hash, auth_method) VALUES"
|
||||
" ('draco-42', 'Dan Test', '[email protected]', NULL, 'draco')")
|
||||
conn.commit()
|
||||
for ouvrir in (True, False):
|
||||
reglages_acces.set_reglages_acces(
|
||||
inscription_locale_ouverte=ouvrir)
|
||||
r = anon_csrf(client).post("/auth/local-login", json={
|
||||
"email": "draco-42", "password": "nimporte"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
# ── #144 : l'échec local suggère la voie ObsiGate (SSO) ─────────────
|
||||
|
||||
def test_echec_connexion_locale_suggere_sso(client):
|
||||
_creer_compte_local("quelquun", "secret123")
|
||||
r = anon_csrf(client).post("/auth/local-login", json={
|
||||
"email": "quelquun", "password": "mauvais-mot-de-passe"})
|
||||
assert r.status_code == 401
|
||||
assert "ObsiGate (SSO)" in r.json()["error"]
|
||||
assert "Hub Dracodev" in r.json()["error"]
|
||||
|
||||
|
||||
def test_echec_connexion_compte_inconnu_suggere_sso(client):
|
||||
r = anon_csrf(client).post("/auth/local-login", json={
|
||||
"email": "[email protected]", "password": "nimporte"})
|
||||
assert r.status_code == 401
|
||||
assert "ObsiGate (SSO)" in r.json()["error"]
|
||||
|
||||
|
||||
# ── Administration du réglage ────────────────────────────────────────
|
||||
|
||||
def test_api_admin_reglages_acces_aller_retour(client):
|
||||
"""Le client de test est administrateur : lecture + écriture."""
|
||||
_fermer()
|
||||
r = client.get("/api/admin/reglages-acces")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["inscription_locale_ouverte"] is False
|
||||
r = client.put("/api/admin/reglages-acces",
|
||||
json={"inscription_locale_ouverte": True})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["inscription_locale_ouverte"] is True
|
||||
assert reglages_acces.inscription_locale_ouverte() is True
|
||||
|
||||
|
||||
def test_api_admin_reglages_acces_refusee_aux_anonymes(client):
|
||||
r = anon_csrf(client).get("/api/admin/reglages-acces")
|
||||
assert r.status_code in (401, 403)
|
||||
@@ -42,6 +42,11 @@ def client():
|
||||
settings.agent_run_timeout_seconds = 30
|
||||
|
||||
init_db()
|
||||
# Fusion #146 : inscription fermée par défaut en production —
|
||||
# _token() crée ses comptes par /auth/register, la suite ouvre
|
||||
# donc le réglage explicitement (comme conftest).
|
||||
from app.services import reglages_acces
|
||||
reglages_acces.set_reglages_acces(inscription_locale_ouverte=True)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO users (login, full_name, email, password_hash, is_admin) "
|
||||
|
||||
Reference in New Issue
Block a user