Files
ObsiGate/backend/auth/middleware.py
T
Spark dcf847e2fd
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
feat: HUB-19 — rôles du Hub consommés par ObsiGate (registre D3(a)) #228
2026-11-06 16:30:30 +00:00

236 lines
8.8 KiB
Python

# backend/auth/middleware.py
# FastAPI dependencies for authentication and authorization.
# Reads JWT from Authorization header OR access_token cookie.
import logging
import os
import sys
from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from backend import draco_session
from backend.services.net import get_client_ip
from .jwt_handler import decode_token, is_token_revoked, maybe_touch_api_token
from .user_store import get_user
logger = logging.getLogger("obsigate.auth.middleware")
security = HTTPBearer(auto_error=False)
#: Hosts considered safe to bind without authentication (loopback only).
_LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost", "0:0:0:0:0:0:0:1"}
def is_auth_enabled() -> bool:
"""Check if authentication is enabled via environment variable.
Default: True (auth enabled). Set OBSIGATE_AUTH_ENABLED=false to disable.
"""
return os.environ.get("OBSIGATE_AUTH_ENABLED", "true").lower() != "false"
def is_insecure_mode_allowed() -> bool:
"""True when the operator explicitly accepts running without auth (BUG-037)."""
return os.environ.get("OBSIGATE_ALLOW_INSECURE", "false").lower() in ("1", "true", "yes", "on")
def bind_host_from_argv(argv: list[str] | None = None) -> str | None:
"""Extract the ``--host`` value from the process arguments (uvicorn), if any.
Returns ``None`` when no explicit host is passed (uvicorn then defaults to
loopback ``127.0.0.1``).
"""
args = sys.argv if argv is None else argv
for i, arg in enumerate(args):
if arg == "--host" and i + 1 < len(args):
return args[i + 1]
if arg.startswith("--host="):
return arg.split("=", 1)[1]
return None
def is_loopback_host(host: str | None) -> bool:
"""True when *host* is a loopback address (or unset → uvicorn default)."""
if not host:
return True
normalized = host.strip().strip("[]").lower()
return normalized in _LOOPBACK_HOSTS
def get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(security),
) -> dict | None:
"""Extract and validate the current user from JWT.
Reads token from Authorization header first, falls back to access_token cookie.
Returns None if no valid token is found.
"""
# If auth is disabled, return a fake admin user with full access
if not is_auth_enabled():
return {
"username": "anonymous",
"display_name": "Anonymous",
"role": "admin",
"vaults": ["*"],
"active": True,
"_token_vaults": ["*"],
"_request_ip": get_client_ip(request),
}
token = None
if credentials:
token = credentials.credentials
elif "access_token" in request.cookies:
token = request.cookies["access_token"]
if not token:
return _draco_fallback(request)
payload = decode_token(token)
if not payload or payload.get("type") != "access":
return _draco_fallback(request)
# BUG-027: access tokens revoked at logout must be rejected immediately.
# (Le repli draco reste sûr : la déconnexion globale révoque aussi
# le jti draco et efface son cookie.)
jti = payload.get("jti")
if jti and is_token_revoked(jti):
return _draco_fallback(request)
user = get_user(payload["sub"])
if not user or not user.get("active"):
return _draco_fallback(request)
# BUG-028: a password change invalidates every token issued before it.
pca = user.get("password_changed_at")
iat = payload.get("iat")
if pca is not None and iat is not None:
try:
if int(iat) < int(float(pca)):
return _draco_fallback(request)
except (TypeError, ValueError):
return _draco_fallback(request)
# Attach vault permissions from the token (snapshot at login time)
user["_token_vaults"] = payload.get("vaults", [])
# Attach the token id for per-token rate limiting (AI tool layer).
user["_token_jti"] = payload.get("jti")
# #199 : périmètre d'endpoints du token (défaut "full" = comportement
# actuel, y compris pour les clés créées avant la portée limitée).
user["_token_scope"] = payload.get("scope", "full")
# Feature #107: track last usage of user-managed API/MCP tokens
# (throttled write — this dependency runs on both REST and /mcp paths).
if payload.get("api"):
maybe_touch_api_token(payload.get("jti"))
# BUG-030: expose the real client IP to the audit log.
user["_request_ip"] = get_client_ip(request)
return user
def _draco_fallback(request: Request) -> dict | None:
"""Fusion ACC-3.2 (#9) : à défaut de jeton natif valide, le cookie
partagé draco_session authentifie l'utilisateur (sub = id
ObsiGate, correspondance directe — voir draco_session.user_for_draco).
"""
user = draco_session.user_for_draco(request)
if user is not None:
user["_request_ip"] = get_client_ip(request)
return 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",
headers={"WWW-Authenticate": "Bearer"},
)
return current_user
def require_admin(current_user=Depends(require_auth)):
"""Dependency: require admin role."""
if current_user.get("role") != "admin":
raise HTTPException(status_code=403, detail="Accès admin requis")
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-")
def check_vault_access(vault_name: str, user: dict) -> bool:
"""Check if a user has access to a specific vault.
Rules:
- vaults == ["*"] → full access (admin default)
- vault_name in vaults → access granted
- otherwise → denied
#194 : un dossier personnel n'est **jamais** couvert par ``*`` — sinon
un admin (``vaults: ["*"]``) verrait le dossier de chaque utilisateur.
"""
vaults = user.get("_token_vaults") or user.get("vaults", [])
if is_home_vault(vault_name):
return vault_name in vaults
if "*" in vaults:
return True
return vault_name in vaults
def require_vault_access(vault_name: str, user: dict = Depends(require_auth)):
"""Dependency: require access to a specific vault."""
if not check_vault_access(vault_name, user):
raise HTTPException(
status_code=403,
detail=f"Accès refusé à la vault '{vault_name}'",
)
return user