"""Administrative auth and Redis-backed rate limiting. Public read endpoints (`/api/v1/movies`, `/search`, ...) are unauthenticated but rate-limited. Administrative/provider endpoints (ingestion triggers, provider config) require an API key via the `X-API-Key` header. """ from __future__ import annotations import time from fastapi import Depends, HTTPException, Request, status from fastapi.security import APIKeyHeader from redis.asyncio import Redis from app.core.config import get_settings from app.core.redis import get_redis _api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) async def require_admin_api_key( api_key: str | None = Depends(_api_key_header), ) -> str: settings = get_settings() allowed = settings.admin_api_keys_set if not allowed: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Administrative API is not configured (AURORA_ADMIN_API_KEYS unset).", ) if not api_key or api_key not in allowed: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing or invalid X-API-Key.", ) return api_key class RedisRateLimiter: """Fixed-window rate limiter backed by Redis `INCR` + `EXPIRE`. Fails open: if Redis is unreachable, requests are allowed through rather than rejected, since Redis is disposable infrastructure and must never become a hard dependency for serving reads. """ def __init__(self, requests_per_minute: int) -> None: self.requests_per_minute = requests_per_minute async def check(self, redis: Redis, key: str) -> tuple[bool, int]: window = int(time.time() // 60) redis_key = f"ratelimit:{key}:{window}" try: count = await redis.incr(redis_key) if count == 1: await redis.expire(redis_key, 60) remaining = max(0, self.requests_per_minute - count) return count <= self.requests_per_minute, remaining except Exception: return True, self.requests_per_minute async def enforce_rate_limit(request: Request, redis: Redis = Depends(get_redis)) -> None: settings = get_settings() if not settings.RATE_LIMIT_ENABLED: return limiter = RedisRateLimiter(settings.RATE_LIMIT_PER_MINUTE) client_id = request.client.host if request.client else "unknown" allowed, remaining = await limiter.check(redis, client_id) if not allowed: raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Rate limit exceeded.", headers={"Retry-After": "60"}, )