Browse Source

Remove authenticated API quotas

Auth users hitting /api/ are already governed by _evaluate's per-user
240 req/min rate limit (NAT-safe, scoped by user pk). The per-day/week
envelope (5 000/day · 35 000/week) fired too early for legitimate
integrations and added needless complexity.

Anonymous callers retain their quota (500/day · 3 500/week) since
they have no persistent per-user rate control.

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

38
sapl/middleware/ratelimit.py

@ -87,8 +87,6 @@ RL_INDEX_API_BLOCKED_IPS = 'rl:index:api_blocked_ips'
# 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.
# ---------------------------------------------------------------------------
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_WEEKLY = 'quota:{ns}:weekly:{week}:ip:{ip}'
@ -310,8 +308,6 @@ class RateLimitMiddleware:
]
self.api_quota_anon_daily = settings.API_QUOTA_ANON_DAILY
self.api_quota_anon_weekly = settings.API_QUOTA_ANON_WEEKLY
self.api_quota_auth_daily = settings.API_QUOTA_AUTH_DAILY
self.api_quota_auth_weekly = settings.API_QUOTA_AUTH_WEEKLY
self.api_rate_limit_enabled = getattr(settings, 'API_RATE_LIMIT_ENABLED', True)
self.api_threshold = getattr(settings, 'API_RATE_LIMIT_THRESHOLD', 60)
self.api_window = getattr(settings, 'API_RATE_LIMIT_WINDOW_SECONDS', 60)
@ -326,11 +322,9 @@ class RateLimitMiddleware:
[p.pattern for p in self._bypass_paths] or '(none)',
)
logger.info(
'[API QUOTAS] daily_anon=%s weekly_anon=%s daily_auth=%s weekly_auth=%s',
'[API QUOTAS] daily_anon=%s weekly_anon=%s (auth: governed by per-user 240/min rate)',
settings.API_QUOTA_ANON_DAILY,
settings.API_QUOTA_ANON_WEEKLY,
settings.API_QUOTA_AUTH_DAILY,
settings.API_QUOTA_AUTH_WEEKLY,
)
logger.info(
'[API RATE LIMIT] enabled=%s threshold=%s window=%ss block=%ss same_origin_bypass=%s',
@ -569,34 +563,28 @@ class RateLimitMiddleware:
def _check_api_quota(self, request):
"""
Increment per-consumer daily and weekly API quota counters.
Returns 'daily' or 'weekly' if the respective limit is exceeded, else None.
Increment per-IP daily and weekly API quota counters for anonymous callers.
Authenticated users are rate-limited per-user by _evaluate (240/min) and
are not subject to a daily quota returns None immediately for auth users.
Fails open (returns None) if Redis/cache is unavailable.
Consumer identity: authenticated users by pk, anonymous by masked IP.
"""
user = getattr(request, 'user', None)
if user and user.is_authenticated:
return None
today = date.today()
iso = today.isocalendar()
date_str = today.isoformat()
week_str = f'{iso[0]}-W{iso[1]:02d}'
user = getattr(request, 'user', None)
if user and user.is_authenticated:
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)
d_limit = self.api_quota_auth_daily
w_limit = self.api_quota_auth_weekly
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)
d_limit = self.api_quota_anon_daily
w_limit = self.api_quota_anon_weekly
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:
if _incr_with_ttl(d_key, 86400) > d_limit:
if _incr_with_ttl(d_key, 86400) > self.api_quota_anon_daily:
return 'daily'
if _incr_with_ttl(w_key, 7 * 86400) > w_limit:
if _incr_with_ttl(w_key, 7 * 86400) > self.api_quota_anon_weekly:
return 'weekly'
except Exception:
pass # fail open — quota not enforced when Redis unavailable

2
sapl/middleware/test_ratelimiter.py

@ -96,8 +96,6 @@ def _make_middleware(
mock_settings.POD_NAMESPACE = _NAMESPACE # keep module-level _NAMESPACE consistent
mock_settings.API_QUOTA_ANON_DAILY = 999999
mock_settings.API_QUOTA_ANON_WEEKLY = 999999
mock_settings.API_QUOTA_AUTH_DAILY = 999999
mock_settings.API_QUOTA_AUTH_WEEKLY = 999999
mock_settings.RATE_LIMITER_UA_BLOCKLIST_REFRESH = 60
mock_settings.API_RATE_LIMIT_ENABLED = api_rate_limit_enabled
mock_settings.API_RATE_LIMIT_THRESHOLD = api_threshold

13
sapl/settings.py

@ -436,16 +436,13 @@ RATE_LIMIT_BYPASS_PATHS = [
r'^/sessao/pauta-sessao/\d+/',
]
# API quota — daily and weekly call caps per consumer (Redis-only, no DB migration).
# Applied only to /api/ paths. Per-consumer: auth users by pk, anon by IP.
# Weekly default = 7 × daily (daily limit is the binding constraint).
# Anon quota is tighter than auth quota — mirrors the rate limiter relationship.
# Both must be > their respective per-minute rate limit thresholds (35 anon, 120 auth),
# otherwise the quota fires before the rate limiter ever engages.
# API quota — daily and weekly call caps for anonymous external callers only.
# Authenticated users are governed by _evaluate's per-user 240/min rate limit
# (NAT-safe, per user pk); a separate daily envelope adds false precision and
# breaks legitimate integrations. Anonymous callers have no persistent per-user
# control, so the daily/weekly quota is their outer envelope.
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_QUOTA_AUTH_DAILY = config('API_QUOTA_AUTH_DAILY', default=5000, cast=int)
API_QUOTA_AUTH_WEEKLY = config('API_QUOTA_AUTH_WEEKLY', default=35000, cast=int)
# 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.

Loading…
Cancel
Save