Browse Source

Apply daily/weekly quota to authenticated API users

Auth users now share the same 500/day · 3 500/week cap as anonymous
callers. Auth quota is keyed by user pk (NAT-safe); anon quota by IP.
Both limits come from a single pair of settings (API_QUOTA_DAILY /
API_QUOTA_WEEKLY), replacing the anon-only API_QUOTA_ANON_* names.
QUOTA_USER_DAILY/WEEKLY Redis key templates are restored accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
rate-limiter-2026
Edward Ribeiro 3 weeks ago
parent
commit
3c4981cc11
  1. 38
      sapl/middleware/ratelimit.py
  2. 18
      sapl/middleware/test_ratelimiter.py
  3. 12
      sapl/settings.py

38
sapl/middleware/ratelimit.py

@ -87,6 +87,8 @@ RL_INDEX_API_BLOCKED_IPS = 'rl:index:api_blocked_ips'
# TTL set only on first INCR (Lua); daily=24h, weekly=7d — cleanup only, # TTL set only on first INCR (Lua); daily=24h, weekly=7d — cleanup only,
# resets are implicit in the date/week embedded in the key name. # resets are implicit in the date/week embedded in the key name.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
QUOTA_USER_DAILY = 'quota:{ns}:daily:{date}:user:{uid}'
QUOTA_USER_WEEKLY = 'quota:{ns}:weekly:{week}:user:{uid}'
QUOTA_IP_DAILY = 'quota:{ns}:daily:{date}:ip:{ip}' QUOTA_IP_DAILY = 'quota:{ns}:daily:{date}:ip:{ip}'
QUOTA_IP_WEEKLY = 'quota:{ns}:weekly:{week}:ip:{ip}' QUOTA_IP_WEEKLY = 'quota:{ns}:weekly:{week}:ip:{ip}'
@ -306,8 +308,8 @@ class RateLimitMiddleware:
self._bypass_paths = [ self._bypass_paths = [
re.compile(p) for p in getattr(settings, 'RATE_LIMIT_BYPASS_PATHS', []) re.compile(p) for p in getattr(settings, 'RATE_LIMIT_BYPASS_PATHS', [])
] ]
self.api_quota_anon_daily = settings.API_QUOTA_ANON_DAILY self.api_quota_daily = settings.API_QUOTA_DAILY
self.api_quota_anon_weekly = settings.API_QUOTA_ANON_WEEKLY self.api_quota_weekly = settings.API_QUOTA_WEEKLY
self.api_rate_limit_enabled = getattr(settings, 'API_RATE_LIMIT_ENABLED', True) self.api_rate_limit_enabled = getattr(settings, 'API_RATE_LIMIT_ENABLED', True)
self.api_threshold = getattr(settings, 'API_RATE_LIMIT_THRESHOLD', 60) self.api_threshold = getattr(settings, 'API_RATE_LIMIT_THRESHOLD', 60)
self.api_window = getattr(settings, 'API_RATE_LIMIT_WINDOW_SECONDS', 60) self.api_window = getattr(settings, 'API_RATE_LIMIT_WINDOW_SECONDS', 60)
@ -322,9 +324,9 @@ class RateLimitMiddleware:
[p.pattern for p in self._bypass_paths] or '(none)', [p.pattern for p in self._bypass_paths] or '(none)',
) )
logger.info( logger.info(
'[API QUOTAS] daily_anon=%s weekly_anon=%s (auth: governed by per-user 240/min rate)', '[API QUOTAS] daily=%s weekly=%s (auth keyed by user pk, anon by IP)',
settings.API_QUOTA_ANON_DAILY, settings.API_QUOTA_DAILY,
settings.API_QUOTA_ANON_WEEKLY, settings.API_QUOTA_WEEKLY,
) )
logger.info( logger.info(
'[API RATE LIMIT] enabled=%s threshold=%s window=%ss block=%ss same_origin_bypass=%s', '[API RATE LIMIT] enabled=%s threshold=%s window=%ss block=%ss same_origin_bypass=%s',
@ -563,28 +565,30 @@ class RateLimitMiddleware:
def _check_api_quota(self, request): def _check_api_quota(self, request):
""" """
Increment per-IP daily and weekly API quota counters for anonymous callers. Increment daily and weekly API quota counters for all /api/ callers.
Authenticated users are rate-limited per-user by _evaluate (240/min) and Auth users are keyed by user pk (NAT-safe); anon callers by IP.
are not subject to a daily quota returns None immediately for auth users. Both groups share the same daily/weekly cap.
Fails open (returns None) if Redis/cache is unavailable. Fails open (returns None) if Redis/cache is unavailable.
""" """
user = getattr(request, 'user', None)
if user and user.is_authenticated:
return None
today = date.today() today = date.today()
iso = today.isocalendar() iso = today.isocalendar()
date_str = today.isoformat() date_str = today.isoformat()
week_str = f'{iso[0]}-W{iso[1]:02d}' week_str = f'{iso[0]}-W{iso[1]:02d}'
ip = get_client_ip(request) user = getattr(request, 'user', None)
d_key = QUOTA_IP_DAILY.format(ns=_NAMESPACE, date=date_str, ip=ip) if user and user.is_authenticated:
w_key = QUOTA_IP_WEEKLY.format(ns=_NAMESPACE, week=week_str, ip=ip) uid = str(user.pk)
d_key = QUOTA_USER_DAILY.format(ns=_NAMESPACE, date=date_str, uid=uid)
w_key = QUOTA_USER_WEEKLY.format(ns=_NAMESPACE, week=week_str, uid=uid)
else:
ip = get_client_ip(request)
d_key = QUOTA_IP_DAILY.format(ns=_NAMESPACE, date=date_str, ip=ip)
w_key = QUOTA_IP_WEEKLY.format(ns=_NAMESPACE, week=week_str, ip=ip)
try: try:
if _incr_with_ttl(d_key, 86400) > self.api_quota_anon_daily: if _incr_with_ttl(d_key, 86400) > self.api_quota_daily:
return 'daily' return 'daily'
if _incr_with_ttl(w_key, 7 * 86400) > self.api_quota_anon_weekly: if _incr_with_ttl(w_key, 7 * 86400) > self.api_quota_weekly:
return 'weekly' return 'weekly'
except Exception: except Exception:
pass # fail open — quota not enforced when Redis unavailable pass # fail open — quota not enforced when Redis unavailable

18
sapl/middleware/test_ratelimiter.py

@ -94,8 +94,8 @@ def _make_middleware(
mock_settings.RATE_LIMIT_404_THRESHOLD = 20 mock_settings.RATE_LIMIT_404_THRESHOLD = 20
mock_settings.RATE_LIMIT_BYPASS_PATHS = [] mock_settings.RATE_LIMIT_BYPASS_PATHS = []
mock_settings.POD_NAMESPACE = _NAMESPACE # keep module-level _NAMESPACE consistent mock_settings.POD_NAMESPACE = _NAMESPACE # keep module-level _NAMESPACE consistent
mock_settings.API_QUOTA_ANON_DAILY = 999999 mock_settings.API_QUOTA_DAILY = 999999
mock_settings.API_QUOTA_ANON_WEEKLY = 999999 mock_settings.API_QUOTA_WEEKLY = 999999
mock_settings.RATE_LIMITER_UA_BLOCKLIST_REFRESH = 60 mock_settings.RATE_LIMITER_UA_BLOCKLIST_REFRESH = 60
mock_settings.API_RATE_LIMIT_ENABLED = api_rate_limit_enabled mock_settings.API_RATE_LIMIT_ENABLED = api_rate_limit_enabled
mock_settings.API_RATE_LIMIT_THRESHOLD = api_threshold mock_settings.API_RATE_LIMIT_THRESHOLD = api_threshold
@ -577,6 +577,20 @@ def test_api_block_response_is_json_with_retry_after():
assert resp['X-RateLimit-Reason'] == 'api_threshold_exceeded' assert resp['X-RateLimit-Reason'] == 'api_threshold_exceeded'
def test_api_auth_user_daily_quota_exceeded_returns_429():
"""Auth users are subject to the same daily quota as anon callers (keyed by pk)."""
mw, _ = _make_middleware()
request = _api_req(ip='10.0.0.1')
request.user = MagicMock(is_authenticated=True, pk=42)
mw.api_quota_daily = 1
with patch('sapl.middleware.ratelimit._incr_with_ttl', return_value=2):
resp = mw(request)
assert resp.status_code == 429
assert resp['X-RateLimit-Reason'] == 'quota_daily'
def test_non_api_path_uses_global_evaluate_not_api_handler(): def test_non_api_path_uses_global_evaluate_not_api_handler():
mw, _ = _make_middleware() mw, _ = _make_middleware()
mw._handle_api = MagicMock() mw._handle_api = MagicMock()

12
sapl/settings.py

@ -436,13 +436,11 @@ RATE_LIMIT_BYPASS_PATHS = [
r'^/sessao/pauta-sessao/\d+/', r'^/sessao/pauta-sessao/\d+/',
] ]
# API quota — daily and weekly call caps for anonymous external callers only. # API quota — daily and weekly call caps for all /api/ callers (anon and auth).
# Authenticated users are governed by _evaluate's per-user 240/min rate limit # Auth users are keyed by user pk (NAT-safe); anon callers by IP.
# (NAT-safe, per user pk); a separate daily envelope adds false precision and # Both groups share the same cap; weekly default is 7× the daily cap.
# breaks legitimate integrations. Anonymous callers have no persistent per-user API_QUOTA_DAILY = config('API_QUOTA_DAILY', default=500, cast=int)
# control, so the daily/weekly quota is their outer envelope. API_QUOTA_WEEKLY = config('API_QUOTA_WEEKLY', default=3500, cast=int)
API_QUOTA_ANON_DAILY = config('API_QUOTA_ANON_DAILY', default=500, cast=int)
API_QUOTA_ANON_WEEKLY = config('API_QUOTA_ANON_WEEKLY', default=3500, cast=int)
# API-specific per-minute rate limit for external (non-same-origin) anonymous calls. # API-specific per-minute rate limit for external (non-same-origin) anonymous calls.
# Abuse writes rl:api:ip:<ip>:blocked only — never rl:ip:<ip>:blocked. # Abuse writes rl:api:ip:<ip>:blocked only — never rl:ip:<ip>:blocked.

Loading…
Cancel
Save