diff --git a/app/api/routes.py b/app/api/routes.py index 24ae284..ac6b490 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -3,6 +3,7 @@ from __future__ import annotations import hashlib +import hmac import json import logging import time @@ -97,6 +98,16 @@ def _authorize(api_key: str | None) -> None: raise HTTPException(status_code=401, detail="Invalid API key") +def _rate_limit_identity(api_key: str | None) -> str: + """Return a non-secret identity that callers cannot control through request fields.""" + + if api_key is None: + return "development-anonymous" + return hmac.new( + api_key.encode("utf-8"), b"deepsequence-rate-limit-identity:v1", hashlib.sha256 + ).hexdigest() + + def _response( request: RecommendRequest, recommendations: list[str], @@ -121,7 +132,7 @@ def recommend( req: RecommendRequest, x_api_key: str | None = Header(default=None) ) -> RecommendResponse: _authorize(x_api_key) - if not _rate_limiter.allow(req.user_id): + if not _rate_limiter.allow(_rate_limit_identity(x_api_key)): raise HTTPException(status_code=429, detail="Recommendation rate limit exceeded") if _runtime is None: raise HTTPException(status_code=503, detail="Model not initialised") diff --git a/tests/test_serving_controls.py b/tests/test_serving_controls.py index 5c53ece..f1b4fa0 100644 --- a/tests/test_serving_controls.py +++ b/tests/test_serving_controls.py @@ -1,7 +1,7 @@ import pytest from fastapi import HTTPException -from app.api.routes import _authorize +from app.api.routes import _authorize, _rate_limit_identity from app.core.config import settings from app.core.security import api_key_is_valid from app.core.serving import RateLimiter, RecommendationCache @@ -14,6 +14,17 @@ def test_rate_limiter_fails_closed_after_budget() -> None: assert limiter.allow("user") is False +def test_rate_limit_identity_is_credential_scoped_and_non_secret() -> None: + limiter = RateLimiter(1) + identity = _rate_limit_identity("shared-credential") + + assert identity != "shared-credential" + assert identity == _rate_limit_identity("shared-credential") + assert limiter.allow(identity) is True + # A caller can change user_id, but cannot change this credential-derived bucket. + assert limiter.allow(_rate_limit_identity("shared-credential")) is False + + def test_cache_is_model_version_aware() -> None: cache = RecommendationCache(ttl_seconds=30) first = cache.key("v1", ["a"], 2)