Sprint 5 : integration et production -- securite, performance, API publique, documentation

Backend: rate limiter, security headers, blockchain cache service avec RPC,
public API (7 endpoints read-only), WebSocket auth + heartbeat, DB connection
pooling, structured logging, health check DB. Frontend: API retry/timeout,
WebSocket auth + heartbeat + typed events, notifications toast, mobile hamburger
+ drawer, error boundary, offline banner, loading skeletons, dashboard enrichi.
Documentation: guides utilisateur complets (demarrage, vote, sanctuaire, FAQ 30+),
guide deploiement, politique securite. 123 tests, 155 fichiers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-02-28 15:12:50 +01:00
parent 3cb1754592
commit 403b94fa2c
31 changed files with 4472 additions and 356 deletions

View File

@@ -0,0 +1,249 @@
"""Public API router: read-only endpoints for external consumption.
All endpoints are accessible without authentication.
No mutations allowed -- strictly read-only.
"""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.decision import Decision
from app.models.document import Document, DocumentItem
from app.models.sanctuary import SanctuaryEntry
from app.models.vote import VoteSession
from app.schemas.document import DocumentFullOut, DocumentItemOut, DocumentOut
from app.schemas.sanctuary import SanctuaryEntryOut
from app.services import document_service, sanctuary_service
router = APIRouter()
# ── Documents (public, read-only) ─────────────────────────────────────────
@router.get(
"/documents",
response_model=list[DocumentOut],
tags=["public-documents"],
summary="Liste publique des documents actifs",
)
async def list_documents(
db: AsyncSession = Depends(get_db),
doc_type: str | None = Query(default=None, description="Filtrer par type de document"),
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
skip: int = Query(default=0, ge=0),
limit: int = Query(default=50, ge=1, le=200),
) -> list[DocumentOut]:
"""Liste les documents de reference avec leurs items (lecture seule)."""
stmt = select(Document)
if doc_type is not None:
stmt = stmt.where(Document.doc_type == doc_type)
if status_filter is not None:
stmt = stmt.where(Document.status == status_filter)
stmt = stmt.order_by(Document.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(stmt)
documents = result.scalars().all()
out = []
for doc in documents:
count_result = await db.execute(
select(func.count()).select_from(DocumentItem).where(DocumentItem.document_id == doc.id)
)
items_count = count_result.scalar() or 0
doc_out = DocumentOut.model_validate(doc)
doc_out.items_count = items_count
out.append(doc_out)
return out
@router.get(
"/documents/{slug}",
response_model=DocumentFullOut,
tags=["public-documents"],
summary="Document complet avec ses items",
)
async def get_document(
slug: str,
db: AsyncSession = Depends(get_db),
) -> DocumentFullOut:
"""Recupere un document avec tous ses items (texte complet, serialise)."""
doc = await document_service.get_document_with_items(slug, db)
if doc is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Document introuvable",
)
return DocumentFullOut.model_validate(doc)
# ── Sanctuary (public, read-only) ─────────────────────────────────────────
@router.get(
"/sanctuary/entries",
response_model=list[SanctuaryEntryOut],
tags=["public-sanctuary"],
summary="Liste des entrees du sanctuaire",
)
async def list_sanctuary_entries(
db: AsyncSession = Depends(get_db),
entry_type: str | None = Query(default=None, description="Filtrer par type (document, decision, vote_result)"),
skip: int = Query(default=0, ge=0),
limit: int = Query(default=50, ge=1, le=200),
) -> list[SanctuaryEntryOut]:
"""Liste les entrees du sanctuaire (archives verifiees)."""
stmt = select(SanctuaryEntry)
if entry_type is not None:
stmt = stmt.where(SanctuaryEntry.entry_type == entry_type)
stmt = stmt.order_by(SanctuaryEntry.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(stmt)
entries = result.scalars().all()
return [SanctuaryEntryOut.model_validate(e) for e in entries]
@router.get(
"/sanctuary/entries/{id}",
response_model=SanctuaryEntryOut,
tags=["public-sanctuary"],
summary="Entree du sanctuaire par ID",
)
async def get_sanctuary_entry(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
) -> SanctuaryEntryOut:
"""Recupere une entree du sanctuaire avec lien IPFS et ancrage on-chain."""
result = await db.execute(
select(SanctuaryEntry).where(SanctuaryEntry.id == id)
)
entry = result.scalar_one_or_none()
if entry is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Entree sanctuaire introuvable",
)
return SanctuaryEntryOut.model_validate(entry)
@router.get(
"/sanctuary/verify/{id}",
tags=["public-sanctuary"],
summary="Verification d'integrite d'une entree",
)
async def verify_sanctuary_entry(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
) -> dict:
"""Verifie l'integrite d'une entree du sanctuaire (comparaison de hash)."""
try:
result = await sanctuary_service.verify_entry(id, db)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
)
return result
# ── Votes (public, read-only) ─────────────────────────────────────────────
@router.get(
"/votes/sessions/{id}/result",
tags=["public-votes"],
summary="Resultat d'une session de vote",
)
async def get_vote_result(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
) -> dict:
"""Recupere le resultat d'une session de vote (lecture seule, public)."""
result = await db.execute(
select(VoteSession).where(VoteSession.id == id)
)
session = result.scalar_one_or_none()
if session is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session de vote introuvable",
)
return {
"session_id": str(session.id),
"status": session.status,
"votes_for": session.votes_for,
"votes_against": session.votes_against,
"votes_total": session.votes_total,
"wot_size": session.wot_size,
"smith_size": session.smith_size,
"techcomm_size": session.techcomm_size,
"threshold_required": session.threshold_required,
"result": session.result,
"starts_at": session.starts_at.isoformat() if session.starts_at else None,
"ends_at": session.ends_at.isoformat() if session.ends_at else None,
"chain_recorded": session.chain_recorded,
"chain_tx_hash": session.chain_tx_hash,
}
# ── Platform status ───────────────────────────────────────────────────────
@router.get(
"/status",
tags=["public-status"],
summary="Statut de la plateforme",
)
async def platform_status(
db: AsyncSession = Depends(get_db),
) -> dict:
"""Statut general de la plateforme: compteurs de documents, decisions, votes actifs."""
# Count documents
doc_count_result = await db.execute(
select(func.count()).select_from(Document)
)
documents_count = doc_count_result.scalar() or 0
# Count decisions
decision_count_result = await db.execute(
select(func.count()).select_from(Decision)
)
decisions_count = decision_count_result.scalar() or 0
# Count active vote sessions
active_votes_result = await db.execute(
select(func.count()).select_from(VoteSession).where(VoteSession.status == "open")
)
active_votes_count = active_votes_result.scalar() or 0
# Count total vote sessions
total_votes_result = await db.execute(
select(func.count()).select_from(VoteSession)
)
total_votes_count = total_votes_result.scalar() or 0
# Count sanctuary entries
sanctuary_count_result = await db.execute(
select(func.count()).select_from(SanctuaryEntry)
)
sanctuary_count = sanctuary_count_result.scalar() or 0
return {
"platform": "Glibredecision",
"documents_count": documents_count,
"decisions_count": decisions_count,
"active_votes_count": active_votes_count,
"total_votes_count": total_votes_count,
"sanctuary_entries_count": sanctuary_count,
}

View File

@@ -1,14 +1,37 @@
"""WebSocket router: live vote updates."""
"""WebSocket router: live vote updates with authentication and heartbeat."""
from __future__ import annotations
import asyncio
import json
import logging
import uuid
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from sqlalchemy import select
from app.database import async_session
from app.models.user import Session as UserSession
router = APIRouter()
logger = logging.getLogger(__name__)
# Heartbeat interval in seconds
_HEARTBEAT_INTERVAL = 30
# Valid notification event types
EVENT_TYPES = (
"vote_submitted",
"vote_update",
"session_closed",
"tally_update",
"decision_advanced",
"mandate_updated",
"document_changed",
"sanctuary_archived",
)
# ── Connection manager ──────────────────────────────────────────────────────
@@ -20,6 +43,8 @@ class ConnectionManager:
def __init__(self) -> None:
# session_id -> list of connected websockets
self._connections: dict[uuid.UUID, list[WebSocket]] = {}
# websocket -> authenticated identity_id (or None for anonymous)
self._authenticated: dict[WebSocket, uuid.UUID | None] = {}
async def connect(self, websocket: WebSocket, session_id: uuid.UUID) -> None:
"""Accept a WebSocket connection and register it for a vote session."""
@@ -36,6 +61,7 @@ class ConnectionManager:
]
if not self._connections[session_id]:
del self._connections[session_id]
self._authenticated.pop(websocket, None)
async def broadcast(self, session_id: uuid.UUID, data: dict[str, Any]) -> None:
"""Broadcast a message to all connections watching a given vote session."""
@@ -55,10 +81,127 @@ class ConnectionManager:
for ws in dead:
self.disconnect(ws, session_id)
async def broadcast_all(self, data: dict[str, Any]) -> None:
"""Broadcast a message to all connected WebSockets across all sessions."""
message = json.dumps(data, default=str)
for session_id in list(self._connections.keys()):
dead: list[WebSocket] = []
for ws in self._connections.get(session_id, []):
try:
await ws.send_text(message)
except Exception:
dead.append(ws)
for ws in dead:
self.disconnect(ws, session_id)
def set_authenticated(self, websocket: WebSocket, identity_id: uuid.UUID) -> None:
"""Mark a WebSocket connection as authenticated."""
self._authenticated[websocket] = identity_id
def is_authenticated(self, websocket: WebSocket) -> bool:
"""Check if a WebSocket connection is authenticated."""
return self._authenticated.get(websocket) is not None
manager = ConnectionManager()
# ── Authentication helper ──────────────────────────────────────────────────
async def _validate_token(token: str) -> uuid.UUID | None:
"""Validate a bearer token and return the associated identity_id.
Uses the same token hashing as auth_service but without FastAPI
dependency injection (since WebSocket doesn't use Depends the same way).
Parameters
----------
token:
The raw bearer token from the query parameter.
Returns
-------
uuid.UUID | None
The identity_id if valid, or None if invalid/expired.
"""
import hashlib
token_hash = hashlib.sha256(token.encode()).hexdigest()
try:
async with async_session() as db:
result = await db.execute(
select(UserSession).where(
UserSession.token_hash == token_hash,
UserSession.expires_at > datetime.now(timezone.utc),
)
)
session = result.scalar_one_or_none()
if session is not None:
return session.identity_id
except Exception:
logger.warning("Erreur lors de la validation du token WebSocket", exc_info=True)
return None
# ── Broadcast event helper (importable by other routers) ──────────────────
async def broadcast_event(
event_type: str,
payload: dict[str, Any],
session_id: uuid.UUID | None = None,
) -> None:
"""Broadcast a notification event to connected WebSocket clients.
This function is designed to be imported and called from other routers
(votes, decisions, mandates, etc.) to push real-time notifications.
Parameters
----------
event_type:
One of the valid EVENT_TYPES.
payload:
The event data to send.
session_id:
If provided, broadcast only to clients watching this specific session.
If None, broadcast to all connected clients.
"""
data = {
"event": event_type,
"timestamp": datetime.now(timezone.utc).isoformat(),
**payload,
}
if session_id is not None:
await manager.broadcast(session_id, data)
else:
await manager.broadcast_all(data)
# ── Heartbeat task ─────────────────────────────────────────────────────────
async def _heartbeat(websocket: WebSocket) -> None:
"""Send periodic ping messages to keep the connection alive.
Runs as a background task alongside the main message loop.
Sends a JSON ping every _HEARTBEAT_INTERVAL seconds.
"""
try:
while True:
await asyncio.sleep(_HEARTBEAT_INTERVAL)
try:
await websocket.send_text(
json.dumps({"event": "ping", "timestamp": datetime.now(timezone.utc).isoformat()})
)
except Exception:
break
except asyncio.CancelledError:
pass
# ── WebSocket endpoint ──────────────────────────────────────────────────────
@@ -66,23 +209,51 @@ manager = ConnectionManager()
async def live_updates(websocket: WebSocket) -> None:
"""WebSocket endpoint for live vote session updates.
The client connects and sends a JSON message with the session_id
they want to subscribe to:
Authentication (optional):
Connect with ``?token=<bearer_token>`` query parameter to authenticate.
If the token is valid, the connection is marked as authenticated.
If missing or invalid, the connection is accepted but unauthenticated.
The client sends JSON messages:
{ "action": "subscribe", "session_id": "<uuid>" }
The server will then push vote update events to the client:
{ "event": "vote_update", "session_id": "...", "votes_for": N, "votes_against": N, "votes_total": N }
{ "event": "session_closed", "session_id": "...", "result": "adopted|rejected" }
The client can also unsubscribe:
{ "action": "unsubscribe", "session_id": "<uuid>" }
The server pushes events:
{ "event": "vote_update", "session_id": "...", ... }
{ "event": "session_closed", "session_id": "...", ... }
{ "event": "vote_submitted", ... }
{ "event": "decision_advanced", ... }
{ "event": "mandate_updated", ... }
{ "event": "document_changed", ... }
{ "event": "sanctuary_archived", ... }
{ "event": "ping", "timestamp": "..." } (heartbeat)
"""
# Extract token from query parameters
token = websocket.query_params.get("token")
await websocket.accept()
subscribed_sessions: set[uuid.UUID] = set()
# Authenticate if token provided
if token:
identity_id = await _validate_token(token)
if identity_id is not None:
manager.set_authenticated(websocket, identity_id)
await websocket.send_text(
json.dumps({"event": "authenticated", "identity_id": str(identity_id)})
)
logger.debug("WebSocket authentifie: identity=%s", identity_id)
else:
await websocket.send_text(
json.dumps({"event": "auth_failed", "detail": "Token invalide ou expire"})
)
logger.debug("Echec authentification WebSocket (token invalide)")
# Start heartbeat task
heartbeat_task = asyncio.create_task(_heartbeat(websocket))
try:
while True:
raw = await websocket.receive_text()
@@ -138,3 +309,11 @@ async def live_updates(websocket: WebSocket) -> None:
# Clean up all subscriptions for this client
for session_id in subscribed_sessions:
manager.disconnect(websocket, session_id)
logger.debug("WebSocket deconnecte, %d souscriptions nettoyees", len(subscribed_sessions))
finally:
# Cancel the heartbeat task
heartbeat_task.cancel()
try:
await heartbeat_task
except asyncio.CancelledError:
pass