feat: HUB-19 — rôles du Hub consommés par ObsiGate (registre D3(a)) #228
CI / lint (pull_request) Successful in 1m17s
CI / security (pull_request) Successful in 21s
CI / test (pull_request) Failing after 3m39s
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped

This commit is contained in:
Spark
2026-11-06 16:30:30 +00:00
parent a3f76e9b0c
commit dcf847e2fd
15 changed files with 618 additions and 20 deletions
+43 -1
View File
@@ -141,9 +141,26 @@ def _draco_fallback(request: Request) -> dict | None:
return user
def require_auth(current_user=Depends(get_current_user)):
def require_auth(request: Request, current_user=Depends(get_current_user)):
"""Dependency: require a valid authenticated user."""
if not current_user:
# HUB-19 (#228) : la session draco a été refusée parce que le
# registre des rôles du Hub attribue le niveau « aucun » à ce
# compte pour ObsiGate — réponse explicite, distincte du 401
# générique. L'authentification native n'est pas concernée :
# ce refus ne naît que sur le chemin draco (user_for_draco).
if getattr(request.state, "role_hub_refuse", None) == "aucun":
raise HTTPException(
status_code=403,
detail=(
"Accès refusé : le registre des rôles du Hub attribue "
"le niveau « aucun » à votre compte pour ObsiGate, "
"l'accès par la session draco est donc refusé. "
"L'administrateur de la suite peut modifier ce niveau "
"dans la matrice des rôles du Hub. L'accès natif "
"d'ObsiGate (son propre identifiant) reste possible."
),
)
raise HTTPException(
status_code=401,
detail="Authentification requise",
@@ -159,6 +176,31 @@ def require_admin(current_user=Depends(require_auth)):
return current_user
def require_admin_hub(current_user=Depends(require_admin)):
"""Dependency: admin + garde du registre des rôles du Hub (HUB-19, #228).
Pour les routes d'administration des comptes (``/api/auth/admin/*``) :
quand la requête est authentifiée par le **canal draco** (``_draco_sub``
présent) et que le rôle du Hub est **connu** (``_hub_role`` non None),
le niveau ``administrateur`` du registre est exigé — le rôle local ne
suffit plus. Session native, ou rôle inconnu (Hub injoignable) :
règles actuelles inchangées.
"""
if current_user.get("_draco_sub"):
role_hub = current_user.get("_hub_role")
if role_hub is not None and role_hub != "administrateur":
raise HTTPException(
status_code=403,
detail=(
"Accès réservé au niveau « administrateur » d'ObsiGate "
"dans le registre des rôles du Hub (votre niveau actuel : "
f"« {role_hub} »). L'administrateur de la suite peut "
"l'accorder dans la matrice des rôles du Hub."
),
)
return current_user
def is_home_vault(vault_name: str) -> bool:
"""Un dossier personnel (#194) : vault « home-<user> »."""
return vault_name.startswith("home-")
+9 -5
View File
@@ -40,7 +40,7 @@ from .mfa import (
verify_recovery_code,
verify_totp,
)
from .middleware import is_auth_enabled, require_admin, require_auth
from .middleware import is_auth_enabled, require_admin_hub, require_auth
from .password import hash_password, validate_password_strength, verify_password
from .user_store import (
create_user,
@@ -957,9 +957,13 @@ async def mfa_recovery_login(body: MfaRecoveryRequest, response: Response, reque
# ── Admin endpoints ───────────────────────────────────────────────────
# Garde `require_admin_hub` (HUB-19, #228) : `require_admin` + — pour
# une requête authentifiée par le canal draco dont le rôle du Hub est
# connu — exigence du niveau « administrateur » du registre des rôles.
# Sessions natives et rôle inconnu : règles actuelles inchangées.
@router.get("/admin/users")
async def list_users(admin=Depends(require_admin)):
async def list_users(admin=Depends(require_admin_hub)):
"""List all users (admin only). Password hashes are never included."""
return get_all_users()
@@ -967,7 +971,7 @@ async def list_users(admin=Depends(require_admin)):
@router.post("/admin/users")
async def create_user_endpoint(
req: CreateUserRequest,
admin=Depends(require_admin),
admin=Depends(require_admin_hub),
):
"""Create a new user (admin only)."""
try:
@@ -994,7 +998,7 @@ async def create_user_endpoint(
async def update_user_endpoint(
username: str,
req: UpdateUserRequest,
admin=Depends(require_admin),
admin=Depends(require_admin_hub),
):
"""Update a user (admin only)."""
updates = req.dict(exclude_none=True)
@@ -1007,7 +1011,7 @@ async def update_user_endpoint(
@router.delete("/admin/users/{username}")
async def delete_user_endpoint(
username: str,
admin=Depends(require_admin),
admin=Depends(require_admin_hub),
):
"""Delete a user (admin only). Cannot delete own account."""
if username == admin["username"]:
+33
View File
@@ -29,6 +29,17 @@
# (issued_sessions_for_user) et la révocation unitaire contrôlée par
# appartenance (revoke_one_for_user), exposées par l'API machine des
# sessions (backend/routers/comptes_machine.py).
#
# Rôles du Hub (HUB-19, #228) : à l'ouverture de session draco,
# `user_for_draco` consulte en plus le registre des rôles du Hub
# (`backend/hub_roles.py`, contrat D3(a) posé par le pilote ShaarIt) :
# niveau connu ``aucun`` → la session draco est refusée (l'authenti-
# fication native d'ObsiGate, émettrice, n'est jamais touchée) ;
# niveau connu → il est joint au dict utilisateur (``_hub_role``)
# pour que les routes d'administration du canal draco exigent
# ``administrateur`` ; rôle inconnu (Hub injoignable) → comportement
# actuel, aucun verrouillage nouveau. Aucun rôle dans le jeton :
# validation locale, émission et révocations sont inchangées.
from __future__ import annotations
@@ -334,6 +345,28 @@ def user_for_draco(request) -> dict | None:
user["_token_vaults"] = candidate.get("vaults", [])
user["_token_scope"] = "full"
user["_draco_sub"] = sub
# HUB-19 (#228) : rôle du registre du Hub pour ce porteur.
# Niveau connu « aucun » → pas de session établie par draco :
# le refus est consigné sur la requête pour que `require_auth`
# réponde un message explicite en français (et non un simple
# 401 générique). Rôle connu → joint au dict (`_hub_role`) ;
# rôle inconnu → `_hub_role` vaut None et le comportement
# actuel s'applique, sans verrouillage nouveau.
from backend import hub_roles
cookie = ""
try:
cookie = request.headers.get("cookie", "") or ""
except Exception: # un objet requête minimal (tests) ne casse rien
cookie = ""
role = hub_roles.role_pour(sub, cookie)
if role == "aucun":
try:
request.state.role_hub_refuse = "aucun"
except Exception: # pas d'état mutable : refus quand même
pass
return None
user["_hub_role"] = role
return user
return None
+126
View File
@@ -0,0 +1,126 @@
# backend/hub_roles.py
# Hub HUB-19 (propagation, #228) — Rôles par application : consommation
# du registre du Hub par ObsiGate, sur le contrat posé par l'hôte
# pilote ShaarIt (0.7.0, décision D3(a) du plan Hub v2).
#
# À l'ouverture de session draco (validation locale du cookie partagé,
# `draco_session.user_for_draco`), ObsiGate demande au Hub le niveau
# du porteur pour l'application `obsigate` :
# ``GET {hub}/api/roles/obsigate``, en relayant l'en-tête Cookie
# complet de la requête, ``Accept: application/json``, délai 2 s.
# Réponse 200 : ``{"app", "sub", "role", "explicite",
# "ttl_secondes": 60}``, rôle ∈ {aucun, utilisateur, administrateur}.
#
# Cache mémoire par `sub`, TTL 60 s, sur le patron du pilote (et du
# cache de révocations draco) : l'horodatage est posé **avant**
# l'appel — un échec conserve l'ancien cache et ne déclenche pas de
# rafale de requêtes vers un Hub en panne.
#
# **Repli obligatoire** : Hub injoignable, en erreur ou réponse
# illisible → rôle **inconnu** (``None``) et l'appelant garde son
# comportement actuel. Le registre ne crée jamais un verrouillage
# nouveau.
#
# URL du Hub : `draco_config.app_url("hub")` — la même résolution que
# la barre commune (#223) : variable explicite `DRACO_HUB_URL` d'abord,
# dérivation par l'hôte sœur (suffixe d'environnement de l'hôte de
# l'agent) ensuite, défaut officiel enfin. Jamais d'URL dev figée.
from __future__ import annotations
import json
import logging
import threading
import time
import urllib.request
logger = logging.getLogger("obsigate.hub_roles")
#: Identifiant d'ObsiGate dans le registre des applications du Hub.
APP = "obsigate"
#: Niveaux reconnus du registre (contrat D3(a)).
ROLES = ("aucun", "utilisateur", "administrateur")
#: TTL du cache, aligné sur ``ttl_secondes`` annoncé par le Hub (60 s).
CACHE_TTL_SECONDS = 60
#: Délai maximal de la consultation du Hub (contrat : 2 s).
DELAI_SECONDS = 2
_lock = threading.Lock()
_cache: dict[str, str] = {} # sub -> rôle connu
_cache_at: float | None = None # horodatage (monotone) du dernier appel
def _hub_url() -> str:
"""Base publique du Hub, résolue comme pour la barre commune."""
from backend.draco_config import app_url
return app_url("hub")
def _consulter_hub(url: str, cookie: str) -> str | None:
"""Appelle le Hub et retourne le rôle validé, ou ``None``.
Toute erreur (réseau, délai dépassé, statut ≠ 200, JSON illisible,
rôle hors contrat) retourne ``None`` : rôle inconnu, repli.
``cookie`` est l'en-tête Cookie complet de la requête, relayé tel
quel — c'est lui qui prouve l'identité du porteur au Hub.
"""
requete = urllib.request.Request(
url,
method="GET",
headers={"Cookie": cookie, "Accept": "application/json"},
)
try:
with urllib.request.urlopen(requete, timeout=DELAI_SECONDS) as reponse:
if reponse.status != 200:
return None
donnees = json.loads(reponse.read().decode("utf-8"))
except Exception: # le registre ne casse jamais une ouverture de session
return None
if not isinstance(donnees, dict):
return None
role = donnees.get("role")
return role if role in ROLES else None
def role_pour(sub: str, cookie: str) -> str | None:
"""Niveau connu pour ce `sub` : un des ``ROLES`` si le Hub a
répondu, ``None`` si le rôle est inconnu (Hub injoignable/erreur)
— repli = comportement actuel de l'appelant.
`cookie` est l'en-tête Cookie complet de la requête d'origine,
relayé tel quel au Hub.
"""
global _cache_at
if not sub:
return None
with _lock:
if _cache_at is not None \
and time.monotonic() - _cache_at < CACHE_TTL_SECONDS \
and sub in _cache:
return _cache[sub]
# Horodatage posé AVANT l'appel (patron du pilote) : l'ancien
# cache reste en vigueur, un échec ne déclenche pas de rafale.
_cache_at = time.monotonic()
url = f"{_hub_url().rstrip('/')}/api/roles/{APP}"
role = _consulter_hub(url, cookie)
if role is None:
logger.warning(
"Registre des rôles du Hub indisponible (%s) — "
"comportement actuel conservé", url)
return None
with _lock:
_cache[sub] = role
_cache_at = time.monotonic()
return role
def reinitialiser_cache() -> None:
"""Vide le cache des rôles (tests, redémarrage à chaud)."""
global _cache_at
with _lock:
_cache.clear()
_cache_at = None