Multi-tenancy : espaces de travail + fix auth reload (rate limiter OPTIONS)

- Modèles Organization + OrgMember, migration Alembic (SQLite compatible)
- organization_id nullable sur Document, Decision, Mandate, VotingProtocol
- Service, schéma, router /organizations + dependency get_active_org_id
- Seed : Duniter G1 + Axiom Team ; tout le contenu seed attaché à Duniter G1
- Backend : list/create filtrés par header X-Organization
- Frontend : store organizations, WorkspaceSelector réel, useApi injecte l'org
- Fix critique : rate_limiter exclut les requêtes OPTIONS (CORS preflight)
  → résout le bug "Failed to fetch /auth/me" au reload (429 sur preflight)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-04-23 15:17:14 +02:00
parent 224e5b0f5e
commit 79e468b40f
31 changed files with 1296 additions and 159 deletions

View File

@@ -64,14 +64,6 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
self._last_cleanup: float = time.time()
self._lock = asyncio.Lock()
def _get_limit_for_path(self, path: str) -> int:
"""Return the rate limit applicable to the given request path."""
if "/auth" in path:
return self.rate_limit_auth
if "/vote" in path:
return self.rate_limit_vote
return self.rate_limit_default
def _get_client_ip(self, request: Request) -> str:
"""Extract the client IP from the request, respecting X-Forwarded-For."""
forwarded = request.headers.get("x-forwarded-for")
@@ -101,6 +93,22 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
if ips_to_delete:
logger.debug("Nettoyage rate limiter: %d IPs supprimees", len(ips_to_delete))
def _get_limit_for_request(self, request: Request) -> int:
"""Return the rate limit applicable to the given request.
CORS preflight (OPTIONS) requests are never rate-limited — blocking them
breaks authenticated cross-origin requests in browsers.
Strict auth limit applies only to POST (login flows), not to GET /auth/me.
"""
if request.method == "OPTIONS":
return 10_000 # effectively unlimited for preflights
path = request.url.path
if request.method == "POST" and "/auth" in path:
return self.rate_limit_auth
if "/vote" in path:
return self.rate_limit_vote
return self.rate_limit_default
async def dispatch(self, request: Request, call_next) -> Response:
"""Check rate limit and either allow the request or return 429."""
# Skip rate limiting for WebSocket upgrades
@@ -111,8 +119,7 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
await self._cleanup_old_entries()
client_ip = self._get_client_ip(request)
path = request.url.path
limit = self._get_limit_for_path(path)
limit = self._get_limit_for_request(request)
now = time.time()
window_start = now - 60
@@ -133,7 +140,7 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
logger.warning(
"Rate limit depasse pour %s sur %s (%d/%d)",
client_ip, path, request_count, limit,
client_ip, request.url.path, request_count, limit,
)
return JSONResponse(