Compare commits

...
12 Commits
Author SHA1 Message Date
bruno c1aa6697f0 Merge pull request 'Flowdeck 7.85.7 — cohérence d'identité au changement de compte' (#59) from coherence-identite-session-draco into main
FlowDeck CI / test (push) Failing after 13m13s
FlowDeck CI / lint (push) Failing after 2s
FlowDeck CI / docker (push) Skipped
2026-11-08 14:43:23 -05:00
Spark f8b228396d Flowdeck 7.85.8 — entrée SSO automatique sans session locale (chantier entrée SSO, empilé sur la 7.85.7)
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / lint (pull_request) Failing after 2s
FlowDeck CI / test (pull_request) Failing after 10m30s
FlowDeck CI / docker (pull_request) Skipped
FlowDeck CI / test (push) Failing after 3h1m6s
FlowDeck CI / docker (push) Skipped
La racine, sans session locale et sur instance peuplée, renvoie
au pont /auth/draco quand un cookie draco valide est présenté,
au lieu de servir la page publique déconnectée. Instance vierge
et formulaire local (?provider=local, le chemin prévu) jamais
interceptés ; sans cookie ou cookie invalide, page publique
inchangée. Symétrique du contrôle de cohérence de la 7.85.7
(session locale d'un autre compte).

Tests : tests/test_entree_sso.py (5) ; suites entrée + cohérence
12/12 ; suite complète comparée à la base (voir PR).
2026-11-08 18:08:35 +00:00
Spark a4852d3889 Flowdeck 7.85.7 — cohérence d'identité au changement de compte : la session locale s'aligne sur le cookie draco (middleware pages, marqueurs de session draco_sub/secours)
FlowDeck CI / lint (push) Failing after 0s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (pull_request) Failing after 1s
FlowDeck CI / test (pull_request) Failing after 1s
FlowDeck CI / docker (pull_request) Skipped
2026-11-08 04:34:10 +00:00
bruno bed7857ada Merge pull request 'Flowdeck 7.85.6 — porte centrale unique (phase 2) : écran réduit à l'entrée ObsiGate SSO + porte de secours administrateur, désactivée par défaut' (#58) from porte-unique-phase2-porte-centrale into main
FlowDeck CI / lint (push) Failing after 4s
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Skipped
2026-11-07 10:31:54 -05:00
bruno 93b91f5f12 Merge pull request 'Flowdeck 7.85.5 — inscription locale fermée par défaut (fusion #146) + échec de connexion nommant la voie SSO (fusion #144)' (#57) from porte-unique-phase1-inscription into main
FlowDeck CI / lint (push) Failing after 3s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-11-07 10:31:34 -05:00
Spark cf2e39e15f Flowdeck 7.85.6 — porte centrale unique (phase 2) : écran réduit à l'entrée ObsiGate SSO + porte de secours administrateur par variables d'environnement, désactivée par défaut
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-11-07 04:52:51 +00:00
Spark 31fe0c181a tests: ouvrir explicitement l'inscription dans les fixtures locales de test_app et test_v66_agent_api (fusion #146)
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (pull_request) Failing after 1s
FlowDeck CI / test (pull_request) Failing after 0s
FlowDeck CI / docker (pull_request) Skipped
2026-11-07 04:52:51 +00:00
Spark 931e4d740b Flowdeck 7.85.5 — inscription locale fermée par défaut (fusion #146) + échec de connexion nommant la voie ObsiGate SSO (fusion #144)
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / lint (pull_request) Failing after 3s
FlowDeck CI / test (pull_request) Failing after 35m42s
FlowDeck CI / docker (pull_request) Skipped
FlowDeck CI / test (push) Failing after 3h10m22s
FlowDeck CI / docker (push) Skipped
2026-11-07 04:29:23 +00:00
bruno 8915c7ddd1 Merge pull request 'Flowdeck 7.85.4 — barre v0.6.0 re-vendangée : entrée « Administration » (fusion #142)' (#56) from menu-compte-admin-v060 into main
FlowDeck CI / lint (push) Failing after 2s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-11-06 15:28:44 -05:00
Spark 0c3b6c522f Flowdeck 7.85.4 — barre v0.6.0 re-vendangée : entrée Administration (sans drapeau, rôle global inconnu) (fusion #142)
FlowDeck CI / lint (push) Failing after 0s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (pull_request) Failing after 0s
FlowDeck CI / test (pull_request) Failing after 0s
FlowDeck CI / docker (pull_request) Skipped
2026-11-06 20:06:31 +00:00
bruno 6ce408059c Merge pull request 'Flowdeck 7.85.3 — Déconnexion du menu compte branchée sur la déconnexion globale (fusion #141)' (#55) from fix/deconnexion-globale-7-85-3 into main
FlowDeck CI / lint (push) Failing after 0s
FlowDeck CI / test (push) Failing after 31m16s
FlowDeck CI / docker (push) Skipped
2026-11-06 14:27:08 -05:00
Spark fcc6cdf830 fix: brancher la déconnexion du menu compte sur la déconnexion globale (fusion #141) — 7.85.3
FlowDeck CI / test (pull_request) Failing after 1s
FlowDeck CI / docker (pull_request) Skipped
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (pull_request) Failing after 10s
2026-11-06 19:01:13 +00:00
23 changed files with 1592 additions and 20 deletions
+45
View File
@@ -1,3 +1,48 @@
## v7.85.8 (2026-11-08) — Entrée SSO automatique sans session locale (chantier « entrée SSO de bout en bout », empilée sur la 7.85.7)
Écart constaté par Bruno en test réel le 2026-11-08 (compte « testeur » créé au Hub) : arrivé chez Flowdeck avec une session centrale valide mais aucune session locale, l'application servait sa page publique déconnectée (« Log in », « Créer un compte dans le Hub ») comme si le cookie draco n'existait pas. Chaîne prouvée en navigateur réel dans une pile locale aux noms publics.
- **Entrée automatique par le pont à la racine** (`app/routers/dashboard/workspace.py`) : sans session locale, si l'instance est peuplée et qu'un cookie `draco_session` **valide** (`app.services.draco.claims_valides`) est présenté, la racine `/` renvoie au pont `/auth/draco?next=/workspaces` (JIT si besoin) au lieu de servir la page publique. **Gardes** : instance vierge (aucun compte) → page publique d'installation inchangée ; cookie absent ou invalide → page publique inchangée ; le pont tranche ensuite (rôle « aucun » → sa page 403 ; échec de résolution → retour au formulaire local `/auth/login?provider=local` — le chemin prévu, celui de tous les liens d'entrée internes — qui n'est jamais intercepté : pas de boucle). Symétrique du contrôle de cohérence de la 7.85.7 (middleware), qui couvre le cas inverse (session locale d'un AUTRE compte).
- **Tests** : nouveau `tests/test_entree_sso.py` (5) — racine renvoyée au pont, chaîne complète jusqu'à la session ouverte, page publique inchangée sans cookie et avec cookie invalide, formulaire local jamais intercepté. Suites entrée + cohérence : 12/12.
## v7.85.7 (2026-11-07) — Cohérence d'identité au changement de compte
Cas réel (capture) : connecté « fred » chez ObsiGate, l'utilisateur ouvre Flowdeck et y trouve son propre compte (BC) — la session locale préexistante gouvernait sans reconsulter l'identité centrale, le pont `/auth/draco` ne s'exécutant qu'à l'entrée SSO sans session locale.
- **Nouveau middleware `CoherenceDracoMiddleware`** (`app/middleware/coherence_draco.py`, le plus externe) : sur les **pages** (GET hors `/api/`, `/auth/`, `/static/`, `/s/`, `/f/`, `/scim/`), quand la requête porte une session locale valide ET un cookie `draco_session` **valide** (nouveau helper public `app.services.draco.claims_valides` : signature, expiration, révocation, `sub` présent) dont le `sub` diffère de celui que la session sert, la session locale est **révoquée** (`user_sessions`), le cookie est supprimé de la réponse et le navigateur est renvoyé au pont `/auth/draco?next=…` (JIT si besoin). Base de comparaison : le `draco_sub` inscrit dans le payload signé à la création par le pont — sinon le lien actif du compte (`draco.current_link`). Le sub inscrit neutralise le repli mono-utilisateur de la résolution (en mono-utilisateur, un sub étranger résout vers l'unique compte et ne doit pas détruire sa session en boucle).
- **Gardes** : aucun cookie ou cookie invalide/expiré/révoqué → rien (une panne de l'émettrice ne déconnecte personne) ; cookie que le pont ne peut pas servir (rôle « aucun » — `RoleAucunRefuse` —, dissociation, compte inactif) ou qui résout vers le même compte → session gardée, pas d'exclusion en boucle ; **compte local sans aucun lien** → l'identité centrale présente gagne (choix documenté ; en mode porte centrale, ce cas n'existe pas hors secours). **API non traversée** : un appel en vol n'est ni interrompu ni rejoué sous une autre identité ; dès la navigation suivante, la page sert l'identité du cookie et la session alignée gouverne l'API.
- **Marqueurs de session** (`app/auth/session.py`) : `create_session(..., draco_sub=…, secours=…)` ; le pont pose `draco_sub`, la connexion de la porte centrale pose `secours=True` (sessions exemptées du contrôle) ; `refresh_session` préserve les marqueurs ; nouveau `decode_payload` (payload complet, sans touch) sur lequel `decode_session` s'appuie.
- **Tests** : nouveau `tests/test_coherence_identite.py` (7) — le cas de la capture (session révoquée en base, pont servant ensuite le compte du cookie, identité lue par `/auth/user`), même sub / sans cookie / cookie invalide intacts, secours exemptée, compte sans lien aligné, marqueur inscrit par le pont et alignement au changement de cookie.
## v7.85.6 (2026-11-06) — Porte centrale unique (phase 2 du programme « compte partout »), désactivée par défaut
> Branche basée sur la 7.85.5 (phase 1, #146/#144) — PR distincte, à fusionner après elle.
- **Nouveau réglage « porte centrale »** (`porte_centrale_active` dans `app/services/reglages_acces.py`, même patron que le réglage d'inscription de la phase 1, **distinct** de lui, **désactivé par défaut à la livraison** ; activable/désactivable par un administrateur via `GET`/`PUT /api/admin/reglages-acces` ou la variable `PORTE_CENTRALE_ACTIVE`).
- **Mode actif** : l'écran de connexion ne présente que l'entrée centrale — le bouton « Login with ObsiGate (SSO) », bien visible, et le lien vers le Hub Dracodev où les comptes se créent (URL dérivée de l'hôte). `POST /auth/local-login` refuse les identifiants locaux ordinaires (403, message nommant la voie SSO), **y compris pour les comptes locaux historiques** ; l'inscription est également fermée dans ce mode. Les sessions déjà établies ne sont pas touchées, et la voie draco (SSO/JIT) reste la porte normale.
- **Porte de secours administrateur** : UN compte administrateur local désigné peut encore s'authentifier localement, uniquement par le chemin discret `GET /auth/login?provider=local&secours=1` (le formulaire y marque ses envois `secours` ; jamais proposé sur l'écran principal). Ses identifiants sont posés par les variables d'environnement `FLOWDECK_SECOURS_ADMIN_LOGIN` et `FLOWDECK_SECOURS_ADMIN_MOT_DE_PASSE` (comparaison à temps constant ; le compte local correspondant doit exister, être actif et administrateur ; aucun secret en dur, aucune valeur par défaut — sans variables, la porte est fermée).
- **Mode inactif** : comportement actuel intégralement préservé (formulaire local, inscription selon le réglage de la phase 1).
- **Tests** : `tests/test_porte_centrale.py` (nouveau, 15 tests) — défaut désactivé vérifié dans le code, réglage distinct de celui d'inscription, refus du compte ordinaire et du compte historique en mode actif, écran réduit à l'entrée centrale (aucun formulaire, aucun « secours »), inscription fermée en mode actif, session établie préservée, secours : écran discret, connexion admin réussie, mauvais mot de passe refusé, absence de drapeau refusée, compte non-admin désigné refusé, absence de variables = porte fermée ; mode inactif = comportement actuel ; aller-retour de l'API d'administration.
## 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.
+1 -1
View File
@@ -1 +1 @@
7.85.2
7.85.8
+43 -5
View File
@@ -36,16 +36,28 @@ class SessionManager:
"""
@staticmethod
def create_session(user_data: dict, request=None) -> str:
def create_session(user_data: dict, request=None, draco_sub: str | None = None,
secours: bool = False) -> str:
"""Create a signed session cookie value.
``request`` is optional — when provided the session is recorded in the
``user_sessions`` table (ip + user agent) and becomes revocable.
Cohérence d'identité (7.85.7) : ``draco_sub`` inscrit dans le
payload signé le sub draco que la session sert (posé par le
pont) — c'est la base de comparaison du contrôle de
cohérence, insensible au repli mono-utilisateur de la
résolution ; ``secours=True`` marque une session de la porte
de secours, exemptée de ce contrôle.
"""
payload = {
"user": public_user(user_data),
"created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
}
if draco_sub:
payload["draco_sub"] = str(draco_sub)
if secours:
payload["secours"] = True
user_id = user_data.get("id")
if user_id:
sid = str(uuid4())
@@ -54,17 +66,31 @@ class SessionManager:
return _serializer.dumps(payload)
@staticmethod
def decode_session(cookie: str) -> dict | None:
"""Decode and validate a session cookie. Returns user data or None."""
def decode_payload(cookie: str) -> dict | None:
"""Decode the full session payload (user, sid, markers), or None.
Same validity checks as ``decode_session`` (signature,
expiration, server-side revocation) but no ``last_seen``
touch and no unwrapping — the coherence middleware reads
the identity markers from here.
"""
try:
payload = _serializer.loads(cookie, max_age=86400 * 7) # 7 days
except (BadSignature, SignatureExpired):
return None
sid = payload.get("sid") or ""
if sid and not _session_active(sid):
# Revoked or deleted session → treat as logged out.
return None
return payload
@staticmethod
def decode_session(cookie: str) -> dict | None:
"""Decode and validate a session cookie. Returns user data or None."""
payload = SessionManager.decode_payload(cookie)
if payload is None:
return None
sid = payload.get("sid") or ""
if sid:
_touch_session(sid)
return payload.get("user")
@@ -103,12 +129,24 @@ class SessionManager:
@staticmethod
def refresh_session(cookie: str, user_data: dict, request=None) -> str:
"""Re-sign a cookie keeping its session id (used after profile edits)."""
"""Re-sign a cookie keeping its session id (used after profile edits).
Les marqueurs de cohérence (``draco_sub``, ``secours`` —
7.85.7) de l'ancien payload sont préservés : un rafraîchi
de profil ne doit ni perdre l'identité servie ni l'exemption
de secours.
"""
sid = SessionManager.session_id(cookie) if cookie else None
payload = {
"user": public_user(user_data),
"created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
}
if cookie:
ancien = SessionManager.decode_payload(cookie)
if ancien:
for marqueur in ("draco_sub", "secours"):
if ancien.get(marqueur):
payload[marqueur] = ancien[marqueur]
user_id = user_data.get("id")
if user_id:
if sid is None:
+19
View File
@@ -46,6 +46,25 @@ 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
# Porte centrale unique (phase 2 du programme « compte partout ») :
# désactivée par défaut à la livraison ; même patron de réglage
# (la ligne en base prime). Activée, seuls la voie ObsiGate (SSO)
# et la porte de secours administrateur ouvrent une session.
porte_centrale_active: bool = False
# Porte de secours administrateur (porte centrale) — repli des
# variables d'environnement FLOWDECK_SECOURS_ADMIN_LOGIN /
# FLOWDECK_SECOURS_ADMIN_MOT_DE_PASSE, lues en priorité par
# `app/services/reglages_acces.py`. Aucune valeur par défaut :
# sans variables posées, la porte de secours est fermée.
secours_admin_login: str = ""
secours_admin_mot_de_passe: str = ""
# Rate limiting
rate_limit_enabled: bool = True
rate_limit_requests: int = 60 # per minute
+8
View File
@@ -280,6 +280,14 @@ app.add_middleware(
from app.middleware.recherche_cors import RechercheCorsMiddleware
app.add_middleware(RechercheCorsMiddleware)
# Cohérence d'identité (7.85.7) : ajoutée en dernier, donc la plus
# externe — une page dont la session locale contredit le cookie
# draco valide présenté est renvoyée au pont avant tout service.
# Le middleware ne traverse que les GET de pages (voir son module).
from app.middleware.coherence_draco import CoherenceDracoMiddleware
app.add_middleware(CoherenceDracoMiddleware)
app.include_router(auth.router)
app.include_router(sso_router)
app.include_router(dashboard.router)
+110
View File
@@ -0,0 +1,110 @@
"""FlowDeck — cohérence d'identité au changement de compte (7.85.7).
Cas réel : connecté « fred » chez ObsiGate, l'utilisateur ouvre
Flowdeck et y trouve son propre compte (BC) — la session locale
préexistante gouvernait sans jamais reconsulter l'identité
centrale, le pont ``/auth/draco`` ne s'exécutant qu'à l'entrée
SSO sans session locale.
Ce middleware aligne la session sur l'identité centrale
**présentée** : quand une requête de page (GET hors ``/api/``,
``/auth/``, ``/static/``, ``/s/``, ``/f/``, ``/scim/``) porte à la
fois une session locale valide et un cookie ``draco_session``
**valide** (``app.services.draco.claims_valides`` : signature,
expiration, révocation) dont le ``sub`` diffère de celui que la
session sert, la session locale est révoquée (ligne
``user_sessions``), le cookie est supprimé de la réponse, et le
navigateur est renvoyé au pont ``/auth/draco?next=…`` (JIT si
besoin). Base de comparaison : le ``draco_sub`` inscrit dans le
payload signé de la session à sa création par le pont — sinon le
lien actif du compte (``draco.current_link``). Le sub inscrit
neutralise le repli mono-utilisateur de la résolution : en
mono-utilisateur, un sub étranger résout vers l'unique compte et
ne doit pas détruire sa session en boucle.
Gardes :
- aucun cookie draco, ou cookie invalide/expiré/révoqué → rien :
une panne ou une absence de l'émettrice ne déconnecte personne ;
- session de la **porte de secours** (marqueur ``secours`` du
payload) → exemptée : elle vit hors circuit central ;
- cookie que le pont ne peut pas servir (rôle « aucun » au
registre — ``user_for_draco`` lève ``RoleAucunRefuse`` —,
dissociation, compte inactif) ou qui résout vers le **même**
compte → session gardée : la détruire exclurait son titulaire
en boucle, le cookie refusé restant présent ;
- compte local **sans aucun lien** draco → l'identité centrale
présente gagne (le pont s'applique ; choix documenté). En mode
porte centrale, ce cas n'existe pas hors secours.
Périmètre : les **pages**. L'API n'est pas traversée par ce
middleware : un appel en vol n'est ni interrompu ni rejoué sous
une autre identité ; dès la navigation suivante, la page sert
l'identité du cookie et la session alignée gouverne l'API.
Après alignement, le ``draco_sub`` de la nouvelle session égale
celui du cookie : pas de boucle.
"""
from __future__ import annotations
import logging
from urllib.parse import quote
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import RedirectResponse
from app.auth.session import SessionManager
logger = logging.getLogger(__name__)
_EXEMPT_PREFIXES = ("/api/", "/auth/", "/static/", "/s/", "/f/", "/scim/")
class CoherenceDracoMiddleware(BaseHTTPMiddleware):
"""Aligne la session locale sur le cookie draco valide présenté."""
async def dispatch(self, request: Request, call_next):
if request.method != "GET":
return await call_next(request)
path = request.url.path
if any(path.startswith(p) for p in _EXEMPT_PREFIXES):
return await call_next(request)
cookie = request.cookies.get("flowdeck_session", "")
if not cookie:
return await call_next(request)
payload = SessionManager.decode_payload(cookie)
if payload is None:
return await call_next(request)
if payload.get("secours"):
return await call_next(request)
user = payload.get("user") or {}
user_id = user.get("id")
if not user_id:
return await call_next(request)
from app.services import draco
claims = draco.claims_valides(request)
if claims is None:
return await call_next(request)
sub_cookie = str(claims.get("sub", ""))
base = payload.get("draco_sub") or draco.current_link(user_id)
if base == sub_cookie:
return await call_next(request)
# Servabilité : le pont doit pouvoir servir un AUTRE compte,
# sinon la session est gardée (pas d'exclusion en boucle).
try:
candidat = draco.user_for_draco(request)
except Exception:
candidat = None
if candidat is None or candidat.get("id") == user_id:
return await call_next(request)
sid = payload.get("sid")
if sid:
SessionManager.revoke_session(sid)
cible = path
if request.url.query:
cible = f"{path}?{request.url.query}"
response = RedirectResponse(
url=f"/auth/draco?next={quote(cible, safe='')}", status_code=302)
response.delete_cookie("flowdeck_session", path="/")
return response
+32
View File
@@ -146,6 +146,38 @@ 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,
"porte_centrale_active": true|false}`` (chaque clé optionnelle).
Inscription fermée (défaut) : aucun compte local ne se crée sur
l'instance, les comptes naissent dans le Hub Dracodev.
Porte centrale (défaut : inactive) : activée, seuls la voie
ObsiGate (SSO) et la porte de secours administrateur ouvrent
une session.
"""
from app.services import reglages_acces
kwargs = {}
if "inscription_locale_ouverte" in body:
kwargs["inscription_locale_ouverte"] = bool(
body["inscription_locale_ouverte"])
if "porte_centrale_active" in body:
kwargs["porte_centrale_active"] = bool(body["porte_centrale_active"])
if kwargs:
return reglages_acces.set_reglages_acces(**kwargs)
return reglages_acces.get_reglages_acces()
# ── Stats ──
@router.get("/stats")
def user_stats(_admin=Depends(admin_required)):
+199 -5
View File
@@ -51,6 +51,125 @@ 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 _page_porte_centrale() -> str:
"""Écran de connexion en mode porte centrale (phase 2).
Ne présente **que** l'entrée centrale : le bouton ObsiGate (SSO),
bien visible. Aucun formulaire local, aucun onglet Register —
la porte de secours administrateur passe par le chemin discret
``?secours=1``, jamais proposé ici.
"""
from app.services import draco_urls
hub = draco_urls.hub_url()
return """<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FlowDeck — Connexion</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:400px;}
.box h1{font-size:24px;margin:0 0 8px;}
.box p{color:rgba(255,255,255,.55);font-size:14px;line-height:1.6;margin:0 0 20px;}
.box a{color:#2383E2;}
.sso{display:flex;align-items:center;justify-content:center;gap:8px;width:100%;padding:14px;border-radius:8px;font-size:15px;font-weight:500;cursor:pointer;border:1px solid rgba(35,131,226,.6);background:#2383E2;color:#fff;text-decoration:none;box-sizing:border-box;}
.sso:hover{background:#2C8CEB;}
</style>
</head>
<body>
<div class="box">
<h1>FlowDeck</h1>
<p>Cette instance n'accepte que la connexion centrale de la suite
Dracodev. Les comptes se créent dans le
<a href="__HUB_URL__">Hub Dracodev</a>.</p>
<a class="sso" href="__SSO_LOGIN_URL__">🧭 Login with ObsiGate (SSO)</a>
</div>
</body>
</html>""".replace("__HUB_URL__", hub)
def _rendre_page_connexion(html: str, secours: bool = False) -> str:
"""Rend la page de connexion en tenant compte des réglages d'accès.
Porte centrale active (phase 2) : seul l'écran central est rendu
— sauf par le chemin discret de secours (``secours=True``), qui
rend le formulaire local en marquant ses envois ``secours``.
Inscription fermée (#146) : 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). Sinon : page intégralement inchangée.
"""
from app.services import draco_urls, reglages_acces
if reglages_acces.porte_centrale_active():
if not secours:
return _with_nonce(_page_porte_centrale())
html = html.replace(
"const body={email,password};",
"const body={email,password,secours:true};")
return _with_nonce(html)
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 +299,18 @@ 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 reglages_acces.porte_centrale_active():
return HTMLResponse(_with_nonce(_page_porte_centrale()),
status_code=403)
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 +335,13 @@ 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)
# Chemin discret de la porte de secours (phase 2) :
# ?secours=1 rend le formulaire local même en mode porte
# centrale — jamais lié depuis l'écran principal.
secours = request.query_params.get("secours") == "1"
return HTMLResponse(
_rendre_page_connexion(LOCAL_LOGIN_HTML, secours=secours),
status_code=200)
# OAuth flow — check if provider is configured
from app.auth.providers import get_provider
@@ -259,6 +395,25 @@ 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 reglages_acces.porte_centrale_active():
from fastapi.responses import JSONResponse
return JSONResponse(
{"error": reglages_acces.message_porte_centrale(),
"hub_url": draco_urls.hub_url()},
status_code=403,
)
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
@@ -310,10 +465,42 @@ def local_login(request: Request, body: dict = Body(default={})):
if not email or not password:
return JSONResponse({"error": "Email and password required"}, status_code=400)
# Porte centrale unique (phase 2) : les identifiants locaux
# ordinaires ne sont plus acceptés — comptes historiques inclus.
# Seule exception : la porte de secours administrateur, par le
# drapeau explicite `secours` (posé par le chemin discret) et les
# identifiants des variables d'environnement. Les sessions déjà
# établies ne passent pas par ici et ne sont pas touchées.
from app.services import reglages_acces as _reglages_acces
if _reglages_acces.porte_centrale_active():
if not body.get("secours"):
return JSONResponse(
{"error": _reglages_acces.message_porte_centrale()},
status_code=403,
)
compte_secours = _reglages_acces.authentifier_secours(email, password)
if compte_secours is None:
return JSONResponse(
{"error": "Porte de secours : identifiants invalides."},
status_code=401,
)
# Session marquée « secours » (7.85.7) : exemptée du
# contrôle de cohérence d'identité — elle vit hors circuit
# central par construction.
session = SessionManager.create_session(
compte_secours, request, secours=True)
_log_login(compte_secours["id"], request)
response = JSONResponse({"status": "ok", "user": {
"login": compte_secours["login"],
"name": compte_secours["full_name"]}})
response.set_cookie("flowdeck_session", session, httponly=True,
max_age=86400 * 7, samesite="lax", path="/")
return response
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 +519,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).
@@ -518,7 +705,14 @@ de l'instance reste possible par la page de connexion.</p>
)
if user is None:
return RedirectResponse(url="/auth/login?provider=local", status_code=302)
session = SessionManager.create_session(user, request)
# Cohérence d'identité (7.85.7) : la session créée par le pont
# inscrit le sub qu'elle sert dans son payload signé — base de
# comparaison du contrôle de cohérence, insensible au repli
# mono-utilisateur de la résolution.
_claims = draco.claims_valides(request)
session = SessionManager.create_session(
user, request,
draco_sub=str(_claims.get("sub", "")) if _claims else None)
_log_login(user["id"], request)
cible = next if next.startswith("/") and not next.startswith("//") else "/workspaces"
response = RedirectResponse(url=cible, status_code=302)
+45 -7
View File
@@ -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,
@@ -39,20 +55,42 @@ async def dashboard(
# ── Not authenticated → show landing page ──
if not user:
# Allow through if DB is empty (fresh install)
_peuplee = False
try:
with get_conn() as conn:
count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
if count == 0:
from app.templating import ENV
env = ENV
template = env.get_template("landing.html")
return template.render()
_peuplee = conn.execute(
"SELECT COUNT(*) FROM users").fetchone()[0] > 0
except Exception:
logger.exception("dashboard")
if _peuplee:
# Entrée automatique par le pont (7.85.8, chantier
# « entrée SSO de bout en bout ») : un cookie draco
# VALIDE présenté sans session locale ne doit pas
# aboutir à la page publique comme si la session de
# la suite n'existait pas — le navigateur est renvoyé
# au pont /auth/draco (JIT si besoin), qui revient à
# /workspaces. Gardes : instance vierge (aucun compte)
# → page publique d'installation, inchangée ; cookie
# absent ou invalide → page publique, inchangée ; le
# pont tranche ensuite (rôle « aucun » → sa page 403 ;
# échec de résolution → retour au formulaire local
# /auth/login?provider=local, le chemin prévu, qui
# n'est jamais intercepté : pas de boucle). Symétrique
# du contrôle de cohérence de la 7.85.7 (middleware),
# qui couvre le cas inverse (session locale d'un
# AUTRE compte).
try:
from app.services import draco as _draco_entree
if _draco_entree.claims_valides(request) is not None:
return RedirectResponse(
url="/auth/draco?next=/workspaces", status_code=302)
except Exception:
logger.exception("dashboard — entrée automatique par le pont")
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)
+24
View File
@@ -149,6 +149,30 @@ def is_revoked(jti: str) -> bool:
return jti in _revoked_cache
def claims_valides(request) -> dict | None:
"""Claims du cookie draco de la requête, validité complète, ou None.
Signature et expiration (``verify_token``), révocation du
``jti`` selon le cache alimenté par l'émettrice, ``sub``
présent — la même validité que celle qu'exige
``user_for_draco`` avant de résoudre. Consommé par le contrôle
de cohérence d'identité (7.85.7) : un cookie absent, invalide,
expiré ou révoqué vaut « aucune identité centrale présentée »
et ne doit rien déclencher.
"""
if not enabled():
return None
claims = verify_token(request.cookies.get(COOKIE_NAME, ""))
if not claims:
return None
jti = str(claims.get("jti", ""))
if jti and is_revoked(jti):
return None
if not str(claims.get("sub", "")):
return None
return claims
# ── Résolution sub → utilisateur local ──────────────────────────────────
def _linked_user(conn, sub: str) -> dict | None:
+215
View File
@@ -0,0 +1,215 @@
"""Réglages d'accès de l'instance — inscription locale (fusion #146)
et porte centrale unique (phase 2 du programme « compte partout »).
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.
Le réglage d'inscription ne touche **jamais** la connexion des
comptes existants, locaux comme draco : il ne garde que la création
de comptes locaux.
Réglage « porte centrale » (phase 2, **distinct** du précédent) :
- **désactivé par défaut à la livraison** ; activé par un
administrateur, l'écran de connexion ne présente que l'entrée
centrale (bouton ObsiGate SSO) et les identifiants locaux
ordinaires ne sont plus acceptés — y compris pour les comptes
locaux historiques. Les sessions déjà établies ne sont pas
touchées, et la voie draco (SSO/JIT) reste la porte normale.
- **Porte de secours administrateur** : UN compte administrateur
local désigné peut encore s'authentifier localement, uniquement
par le chemin discret ``/auth/login?provider=local&secours=1``
(jamais proposé sur l'écran principal). Ses identifiants sont
posés par les variables d'environnement
``FLOWDECK_SECOURS_ADMIN_LOGIN`` et
``FLOWDECK_SECOURS_ADMIN_MOT_DE_PASSE`` — aucun secret en dur ;
sans ces variables, la porte de secours est fermée.
"""
from __future__ import annotations
import logging
import os
import secrets
from app.config import settings
logger = logging.getLogger(__name__)
__all__ = [
"get_reglages_acces",
"set_reglages_acces",
"inscription_locale_ouverte",
"message_inscription_fermee",
"porte_centrale_active",
"message_porte_centrale",
"authentifier_secours",
]
def _assurer_table(conn) -> None:
"""Crée la table des réglages d'accès si elle n'existe pas encore
et y ajoute les colonnes des phases ultérieures si besoin."""
conn.execute("""
CREATE TABLE IF NOT EXISTS reglages_acces (
id INTEGER PRIMARY KEY CHECK (id = 1),
inscription_locale_ouverte INTEGER,
porte_centrale_active INTEGER,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
colonnes = {r[1] for r in conn.execute("PRAGMA table_info(reglages_acces)")}
if "porte_centrale_active" not in colonnes:
conn.execute(
"ALTER TABLE reglages_acces ADD COLUMN porte_centrale_active INTEGER")
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)),
# Défaut du code et de la config : porte centrale désactivée.
"porte_centrale_active": bool(
getattr(settings, "porte_centrale_active", False)),
}
try:
from app.db import get_conn
with get_conn() as conn:
_assurer_table(conn)
row = conn.execute(
"SELECT inscription_locale_ouverte, porte_centrale_active"
" 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:
if row["inscription_locale_ouverte"] is not None:
reglages["inscription_locale_ouverte"] = bool(
row["inscription_locale_ouverte"])
if row["porte_centrale_active"] is not None:
reglages["porte_centrale_active"] = bool(
row["porte_centrale_active"])
return reglages
def set_reglages_acces(*, inscription_locale_ouverte: bool | None = None,
porte_centrale_active: 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)
if porte_centrale_active is not None:
actuels["porte_centrale_active"] = bool(porte_centrale_active)
with get_conn() as conn:
_assurer_table(conn)
conn.execute(
"INSERT INTO reglages_acces (id, inscription_locale_ouverte,"
" porte_centrale_active, updated_at) VALUES (1, ?, ?,"
" CURRENT_TIMESTAMP)"
" ON CONFLICT(id) DO UPDATE SET"
" inscription_locale_ouverte=excluded.inscription_locale_ouverte,"
" porte_centrale_active=excluded.porte_centrale_active,"
" updated_at=CURRENT_TIMESTAMP",
(1 if actuels["inscription_locale_ouverte"] else 0,
1 if actuels["porte_centrale_active"] 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 porte_centrale_active() -> bool:
"""Vrai si la porte centrale unique est active sur l'instance."""
return bool(get_reglages_acces()["porte_centrale_active"])
def message_porte_centrale() -> str:
"""Message affiché quand la porte centrale refuse le local."""
from app.services import draco_urls
return (
"La porte centrale est active sur cette instance : "
"connectez-vous avec le bouton « Login with ObsiGate (SSO) » "
"— les comptes se créent dans le Hub Dracodev "
f"({draco_urls.hub_url()})."
)
def _secours_identifiants() -> tuple[str, str]:
"""Identifiants de la porte de secours — variables d'environnement
en priorité, repli sur la configuration. Jamais de valeur par
défaut : vide = porte de secours fermée."""
login = (os.environ.get("FLOWDECK_SECOURS_ADMIN_LOGIN", "")
or getattr(settings, "secours_admin_login", "") or "").strip()
mot_de_passe = (os.environ.get("FLOWDECK_SECOURS_ADMIN_MOT_DE_PASSE", "")
or getattr(settings, "secours_admin_mot_de_passe", "") or "")
return login, mot_de_passe
def authentifier_secours(login: str, mot_de_passe: str) -> dict | None:
"""Authentifie le compte administrateur de secours désigné.
Les identifiants fournis doivent correspondre **exactement** aux
variables d'environnement de la porte de secours (comparaison à
temps constant), et le compte local correspondant doit exister,
être actif et être administrateur. Renvoie le compte (dict) ou
``None`` — sans jamais dire quelle condition a échoué.
"""
attendu_login, attendu_mdp = _secours_identifiants()
if not attendu_login or not attendu_mdp:
return None
if not secrets.compare_digest((login or "").strip(), attendu_login):
return None
if not secrets.compare_digest(mot_de_passe or "", attendu_mdp):
return None
from app.db import get_conn
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM users WHERE login=? OR email=?",
(attendu_login, attendu_login),
).fetchone()
if row is None:
return None
compte = dict(row)
if not compte.get("is_admin") or not compte.get("is_active"):
return None
return compte
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) »."
)
+22
View File
@@ -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(); }
+2 -2
View File
@@ -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>
+5
View File
@@ -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 {
+67
View File
@@ -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;
}
};
+8
View File
@@ -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
+6
View File
@@ -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
+190
View File
@@ -0,0 +1,190 @@
"""Cohérence d'identité au changement de compte (7.85.7).
Cas réel (capture de Bruno) : connecté « fred » chez ObsiGate, il
ouvre Flowdeck et y trouve son propre compte — la session locale
préexistante gouvernait sans reconsulter l'identité centrale, le
pont ne s'exécutant qu'à l'entrée SSO sans session locale.
Le middleware ``CoherenceDracoMiddleware`` aligne la session sur
le cookie draco valide présenté : révocation de la session locale
+ renvoi des pages au pont. Gardes couvertes ici : même sub
(inchangée), aucun cookie / cookie invalide (intacte), session de
secours marquée (exemptée), compte local sans aucun lien
(l'identité centrale présente gagne), marqueur ``draco_sub``
inscrit par le pont dans le payload de session.
"""
import base64
import hashlib
import hmac
import json
import time
import pytest
from app.auth.session import SessionManager
from app.db import get_conn
from app.password_utils import hash_password
from app.services import draco
from tests.conftest import anon
SECRET = "coherence-secret"
SUB_PROPRIO = "coh-sub-proprio"
SUB_FRED = "coh-sub-fred"
MDP = "mot-de-passe-coh"
def _jeton(sub, secret=SECRET, exp=None):
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": "Testeur", "iat": int(time.time()),
"exp": exp if exp is not None else int(time.time()) + 3600,
"jti": f"jti-{sub}",
}).encode())
sig = hmac.new(secret.encode(), f"{header}.{payload}".encode(),
hashlib.sha256).digest()
return f"{header}.{payload}.{b64(sig)}"
@pytest.fixture(autouse=True)
def _env(monkeypatch):
monkeypatch.setenv("DRACO_SESSION_SECRET", SECRET)
monkeypatch.setattr(draco, "_refresh_revocations", lambda: None)
draco._revoked_cache.clear()
yield
draco._revoked_cache.clear()
def _creer(login, sub=None):
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO users (login, email, password_hash, full_name, is_active) "
"VALUES (?, ?, ?, ?, 1)",
(login, f"{login}@ex.ca", hash_password(MDP), login.title()))
uid = cur.lastrowid
if sub:
conn.execute(
"INSERT INTO draco_links (sub, user_id) VALUES (?, ?)",
(sub, uid))
conn.commit()
return uid
def _connecter(client, login):
r = client.post("/auth/local-login",
json={"email": login, "password": MDP})
assert r.status_code == 200, r.text
@pytest.fixture()
def deux_comptes(client):
proprio_id = _creer("cohproprio", SUB_PROPRIO)
fred_id = _creer("cohfred", SUB_FRED)
return proprio_id, fred_id
# ── (iii) le cas de Bruno : session d'un autre compte ────────────
def test_session_autre_compte_alignee_sur_le_cookie(client, deux_comptes):
proprio_id, _ = deux_comptes
anon(client)
_connecter(client, "cohproprio")
client.cookies.set("draco_session", _jeton(SUB_FRED))
r = client.get("/workspaces", follow_redirects=False)
assert r.status_code == 302
assert r.headers["location"].startswith("/auth/draco")
assert "Max-Age=0" in r.headers.get("set-cookie", "")
# La session locale du proprio est révoquée côté serveur.
with get_conn() as conn:
rows = conn.execute(
"SELECT revoked FROM user_sessions WHERE user_id = ?",
(proprio_id,)).fetchall()
assert rows and all(row["revoked"] == 1 for row in rows)
# Le pont sert ensuite le compte du cookie.
r = client.get("/auth/draco", follow_redirects=False)
assert r.status_code == 302
assert r.headers["location"] == "/workspaces"
me = client.get("/auth/user")
assert me.json()["authenticated"] is True
assert me.json()["user"]["login"] == "cohfred"
# ── (ii) même sub, (iv) pas de cookie, cookie invalide ───────────
def test_meme_sub_session_inchangee(client, deux_comptes):
anon(client)
_connecter(client, "cohproprio")
client.cookies.set("draco_session", _jeton(SUB_PROPRIO))
r = client.get("/workspaces", follow_redirects=False)
assert r.status_code == 200
me = client.get("/auth/user")
assert me.json()["user"]["login"] == "cohproprio"
def test_sans_cookie_session_intacte(client, deux_comptes):
anon(client)
_connecter(client, "cohproprio")
r = client.get("/workspaces", follow_redirects=False)
assert r.status_code == 200
def test_cookie_invalide_session_intacte(client, deux_comptes):
anon(client)
_connecter(client, "cohproprio")
client.cookies.set(
"draco_session", _jeton(SUB_FRED, secret="mauvais-secret"))
r = client.get("/workspaces", follow_redirects=False)
assert r.status_code == 200
me = client.get("/auth/user")
assert me.json()["user"]["login"] == "cohproprio"
# ── (v) session de secours exemptée ──────────────────────────────
def test_session_secours_exemptee(client, deux_comptes):
proprio_id, _ = deux_comptes
anon(client)
session = SessionManager.create_session(
{"id": proprio_id, "login": "cohproprio",
"full_name": "Cohproprio", "is_admin": 0},
None, secours=True)
client.cookies.set("flowdeck_session", session)
client.cookies.set("draco_session", _jeton(SUB_FRED))
r = client.get("/workspaces", follow_redirects=False)
assert r.status_code == 200
# ── Garde 4 : compte sans lien, l'identité centrale gagne ────────
def test_compte_sans_lien_aligne(client):
_creer("cohproprio") # aucun lien draco
_creer("cohfred", SUB_FRED)
anon(client)
_connecter(client, "cohproprio")
client.cookies.set("draco_session", _jeton(SUB_FRED))
r = client.get("/workspaces", follow_redirects=False)
assert r.status_code == 302
assert r.headers["location"].startswith("/auth/draco")
# ── Marqueur du pont ─────────────────────────────────────────────
def test_pont_inscrit_le_sub_servi_dans_la_session(client, deux_comptes):
anon(client)
client.cookies.set("draco_session", _jeton(SUB_FRED))
r = client.get("/auth/draco", follow_redirects=False)
assert r.status_code == 302
brut = client.cookies.get("flowdeck_session")
payload = SessionManager.decode_payload(brut)
assert payload is not None
assert payload.get("draco_sub") == SUB_FRED
# Changement de compte central : le cookie du proprio aligne
# la session (base = sub inscrit, pas le lien courant).
client.cookies.set("draco_session", _jeton(SUB_PROPRIO))
r = client.get("/workspaces", follow_redirects=False)
assert r.status_code == 302
assert r.headers["location"].startswith("/auth/draco")
+29
View File
@@ -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
+118
View File
@@ -0,0 +1,118 @@
"""Entrée SSO automatique sans session locale (7.85.8).
Chantier « entrée SSO de bout en bout » — écart constaté par
Bruno en test réel le 2026-11-08 : arrivé chez Flowdeck avec une
session centrale valide (cookie ``draco_session``) mais aucune
session locale, l'application servait sa page publique
déconnectée (« Log in », « Notion-style ») comme
si le cookie n'existait pas. Attendu : à l'arrivée sur la racine,
passage automatique par le pont ``/auth/draco`` et service de
l'application connectée au compte désigné par le cookie.
Couvert : redirection de la racine vers le pont, chaîne complète
jusqu'à la session ouverte, page publique inchangée sans cookie
ou avec un cookie invalide, formulaire local jamais intercepté
(le chemin prévu ``?provider=local``), pont qui tranche en cas de
rôle « aucun » (403, pas de boucle).
Montage repris de tests/test_coherence_identite.py.
"""
import base64
import hashlib
import hmac
import json
import time
import pytest
from app.db import get_conn
from app.password_utils import hash_password
from app.services import draco
from tests.conftest import anon
SECRET = "entree-secret"
SUB_TESTEUR = "ent-sub-testeur"
MDP = "mot-de-passe-ent"
def _jeton(sub, secret=SECRET):
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": "Testeur", "iat": int(time.time()),
"exp": int(time.time()) + 3600, "jti": f"jti-{sub}",
}).encode())
sig = hmac.new(secret.encode(), f"{header}.{payload}".encode(),
hashlib.sha256).digest()
return f"{header}.{payload}.{b64(sig)}"
@pytest.fixture(autouse=True)
def _env(monkeypatch):
monkeypatch.setenv("DRACO_SESSION_SECRET", SECRET)
monkeypatch.setattr(draco, "_refresh_revocations", lambda: None)
draco._revoked_cache.clear()
yield
draco._revoked_cache.clear()
@pytest.fixture()
def compte_lie(client):
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO users (login, email, password_hash, full_name, is_active) "
"VALUES (?, ?, ?, ?, 1)",
("enttesteur", "[email protected]", hash_password(MDP),
"Testeur Test"))
uid = cur.lastrowid
conn.execute(
"INSERT INTO draco_links (sub, user_id) VALUES (?, ?)",
(SUB_TESTEUR, uid))
conn.commit()
return uid
def test_racine_sans_session_cookie_valide_renvoyee_au_pont(client, compte_lie):
anon(client)
client.cookies.set("draco_session", _jeton(SUB_TESTEUR))
r = client.get("/", follow_redirects=False)
assert r.status_code == 302
assert r.headers["location"] == "/auth/draco?next=/workspaces"
def test_chaine_complete_session_ouverte(client, compte_lie):
anon(client)
client.cookies.set("draco_session", _jeton(SUB_TESTEUR))
r = client.get("/auth/draco", follow_redirects=False)
assert r.status_code == 302
assert r.headers["location"] == "/workspaces"
assert "flowdeck_session" in r.headers.get("set-cookie", "")
# La racine ne sert plus la page publique.
r2 = client.get("/", follow_redirects=False)
assert "Notion-style" not in r2.text
def test_racine_sans_cookie_page_publique_inchangee(client, compte_lie):
anon(client)
r = client.get("/", follow_redirects=False)
assert r.status_code == 200
assert "Notion-style" in r.text
def test_racine_cookie_invalide_page_publique_inchangee(client, compte_lie):
anon(client)
client.cookies.set("draco_session", _jeton(SUB_TESTEUR, secret="mauvais"))
r = client.get("/", follow_redirects=False)
assert r.status_code == 200
assert "Notion-style" in r.text
def test_formulaire_local_jamais_intercepte(client, compte_lie):
anon(client)
client.cookies.set("draco_session", _jeton(SUB_TESTEUR))
r = client.get("/auth/login?provider=local", follow_redirects=False)
assert r.status_code == 200
+222
View File
@@ -0,0 +1,222 @@
"""FlowDeck — porte centrale unique (phase 2, programme « compte
partout »), basée sur les réglages d'accès de la phase 1 (#146).
Contrat :
- réglage « porte centrale » **distinct** de celui d'inscription,
**désactivé par défaut** à la livraison ;
- mode actif : l'écran de connexion ne présente que l'entrée
centrale (bouton ObsiGate SSO) et les identifiants locaux
ordinaires sont refusés — comptes historiques inclus ; les
sessions déjà établies ne sont pas touchées ;
- **porte de secours** : UN compte administrateur local désigné,
identifiants posés par les variables d'environnement
``FLOWDECK_SECOURS_ADMIN_LOGIN`` /
``FLOWDECK_SECOURS_ADMIN_MOT_DE_PASSE``, authentification locale
uniquement par le chemin discret ``?secours=1`` (drapeau
``secours`` à l'envoi) — jamais proposé sur l'écran principal ;
- mode inactif : comportement actuel intégralement préservé.
"""
from __future__ import annotations
import pytest
from conftest import anon_csrf
from app.services import reglages_acces
LOGIN_SECOURS = "admin-secours"
MDP_SECOURS = "mot-de-passe-de-secours-designe"
def _activer():
reglages_acces.set_reglages_acces(porte_centrale_active=True)
def _desactiver():
reglages_acces.set_reglages_acces(porte_centrale_active=False)
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()
@pytest.fixture
def secours_env(monkeypatch):
"""Pose les variables d'environnement de la porte de secours."""
monkeypatch.setenv("FLOWDECK_SECOURS_ADMIN_LOGIN", LOGIN_SECOURS)
monkeypatch.setenv("FLOWDECK_SECOURS_ADMIN_MOT_DE_PASSE", MDP_SECOURS)
_creer_compte_local(LOGIN_SECOURS, "ancien-mot-de-passe-local", admin=1)
return monkeypatch
# ── Défaut et distinction des réglages ──────────────────────────────
def test_porte_centrale_desactivee_par_defaut_dans_le_code():
from app.config import settings
assert settings.porte_centrale_active is False
def test_reglage_distinct_de_celui_d_inscription(client):
reglages_acces.set_reglages_acces(
inscription_locale_ouverte=True, porte_centrale_active=False)
reglages = reglages_acces.get_reglages_acces()
assert reglages["inscription_locale_ouverte"] is True
assert reglages["porte_centrale_active"] is False
# ── Mode actif : le local ordinaire est refusé ──────────────────────
def test_mode_actif_refuse_compte_ordinaire(client):
_creer_compte_local("ordinaire", "secret123")
_activer()
r = anon_csrf(client).post("/auth/local-login", json={
"email": "ordinaire", "password": "secret123"})
assert r.status_code == 403
assert "ObsiGate (SSO)" in r.json()["error"]
def test_mode_actif_refuse_compte_local_historique(client):
"""Compte créé par inscription publique (quand elle était
ouverte), puis porte activée : son mot de passe local ne passe
plus."""
reglages_acces.set_reglages_acces(
inscription_locale_ouverte=True, porte_centrale_active=False)
r = client.post("/auth/register", json={
"email": "[email protected]", "password": "secret123",
"name": "Historique"})
assert r.status_code == 200
_activer()
r = anon_csrf(client).post("/auth/local-login", json={
"email": "[email protected]", "password": "secret123"})
assert r.status_code == 403
def test_mode_actif_ecran_connexion_que_l_entree_centrale(client):
_activer()
r = anon_csrf(client).get("/auth/login?provider=local")
assert r.status_code == 200
# L'entrée centrale, bien visible…
assert "Login with ObsiGate (SSO)" in r.text
# … et rien d'autre : ni formulaire local, ni onglet Register,
# ni lien vers le chemin discret de secours.
assert 'id="login-form"' not in r.text
assert 'id="password"' not in r.text
assert 'id="tab-register"' not in r.text
assert "secours" not in r.text
def test_mode_actif_inscription_aussi_fermee(client):
reglages_acces.set_reglages_acces(inscription_locale_ouverte=True)
_activer()
r = client.post("/auth/register", json={
"email": "[email protected]", "password": "secret123"})
assert r.status_code == 403
def test_mode_actif_session_deja_etablie_non_touchee(client):
_creer_compte_local("deja-dedans", "secret123")
c = anon_csrf(client)
r = c.post("/auth/local-login", json={
"email": "deja-dedans", "password": "secret123"})
assert r.status_code == 200
_activer()
r = c.get("/auth/user")
assert r.json()["authenticated"] is True
# ── Porte de secours administrateur ─────────────────────────────────
def test_secours_connexion_admin_par_le_chemin_discret(client, secours_env):
_activer()
# L'écran discret rend le formulaire, marqué « secours ».
r = anon_csrf(client).get("/auth/login?provider=local&secours=1")
assert r.status_code == 200
assert 'id="login-form"' in r.text
assert "secours:true" in r.text
# L'authentification elle-même : identifiants d'environnement.
r = anon_csrf(client).post("/auth/local-login", json={
"email": LOGIN_SECOURS, "password": MDP_SECOURS,
"secours": True})
assert r.status_code == 200
assert r.json()["status"] == "ok"
assert "flowdeck_session" in r.headers.get("set-cookie", "")
def test_secours_mauvais_mot_de_passe_environnement_refuse(client, secours_env):
_activer()
r = anon_csrf(client).post("/auth/local-login", json={
"email": LOGIN_SECOURS, "password": "pas-le-bon",
"secours": True})
assert r.status_code == 401
def test_secours_sans_drapeau_refuse_meme_pour_l_admin(client, secours_env):
"""Sans le drapeau du chemin discret, même les identifiants de
secours sont traités comme un envoi ordinaire : refusés."""
_activer()
r = anon_csrf(client).post("/auth/local-login", json={
"email": LOGIN_SECOURS, "password": MDP_SECOURS})
assert r.status_code == 403
def test_secours_compte_non_admin_refuse(client, secours_env, monkeypatch):
"""Le compte désigné doit être administrateur : un compte
ordinaire désigné par les variables ne passe pas."""
_creer_compte_local("pas-admin", "secret123", admin=0)
monkeypatch.setenv("FLOWDECK_SECOURS_ADMIN_LOGIN", "pas-admin")
monkeypatch.setenv("FLOWDECK_SECOURS_ADMIN_MOT_DE_PASSE", "secret123")
_activer()
r = anon_csrf(client).post("/auth/local-login", json={
"email": "pas-admin", "password": "secret123", "secours": True})
assert r.status_code == 401
def test_secours_sans_variables_environnement_fermee(client, monkeypatch):
monkeypatch.delenv("FLOWDECK_SECOURS_ADMIN_LOGIN", raising=False)
monkeypatch.delenv("FLOWDECK_SECOURS_ADMIN_MOT_DE_PASSE", raising=False)
_creer_compte_local("admin-local", "secret123", admin=1)
_activer()
r = anon_csrf(client).post("/auth/local-login", json={
"email": "admin-local", "password": "secret123", "secours": True})
assert r.status_code == 401
# ── Mode inactif : comportement actuel intégralement préservé ───────
def test_mode_inactif_connexion_locale_comme_avant(client):
_desactiver()
_creer_compte_local("normal", "secret123")
r = anon_csrf(client).post("/auth/local-login", json={
"email": "normal", "password": "secret123"})
assert r.status_code == 200
assert r.json()["status"] == "ok"
def test_mode_inactif_ecran_connexion_complet(client):
_desactiver()
r = anon_csrf(client).get("/auth/login?provider=local")
assert r.status_code == 200
assert 'id="login-form"' in r.text
assert "Login with ObsiGate (SSO)" in r.text
def test_api_admin_aller_retour_porte_centrale(client):
_desactiver()
r = client.put("/api/admin/reglages-acces",
json={"porte_centrale_active": True})
assert r.status_code == 200
assert r.json()["porte_centrale_active"] is True
r = client.get("/api/admin/reglages-acces")
assert r.json()["porte_centrale_active"] is True
_desactiver()
+177
View File
@@ -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)
+5
View File
@@ -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) "