diff --git a/sapl/middleware/ratelimit.py b/sapl/middleware/ratelimit.py index b945d08f5..7893b358d 100644 --- a/sapl/middleware/ratelimit.py +++ b/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, # 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}' @@ -306,8 +308,8 @@ class RateLimitMiddleware: self._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_anon_weekly = settings.API_QUOTA_ANON_WEEKLY + self.api_quota_daily = settings.API_QUOTA_DAILY + self.api_quota_weekly = settings.API_QUOTA_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) @@ -322,9 +324,9 @@ class RateLimitMiddleware: [p.pattern for p in self._bypass_paths] or '(none)', ) logger.info( - '[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, + '[API QUOTAS] daily=%s weekly=%s (auth keyed by user pk, anon by IP)', + settings.API_QUOTA_DAILY, + settings.API_QUOTA_WEEKLY, ) logger.info( '[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): """ - 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. + Increment daily and weekly API quota counters for all /api/ callers. + Auth users are keyed by user pk (NAT-safe); anon callers by IP. + Both groups share the same daily/weekly cap. 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() iso = today.isocalendar() date_str = today.isoformat() week_str = f'{iso[0]}-W{iso[1]:02d}' - 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) + 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) + 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: - 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' - 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' except Exception: pass # fail open — quota not enforced when Redis unavailable diff --git a/sapl/middleware/test_ratelimiter.py b/sapl/middleware/test_ratelimiter.py index b0b20bfc6..cc69a66e0 100644 --- a/sapl/middleware/test_ratelimiter.py +++ b/sapl/middleware/test_ratelimiter.py @@ -94,8 +94,8 @@ def _make_middleware( mock_settings.RATE_LIMIT_404_THRESHOLD = 20 mock_settings.RATE_LIMIT_BYPASS_PATHS = [] 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_DAILY = 999999 + mock_settings.API_QUOTA_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 @@ -577,6 +577,20 @@ def test_api_block_response_is_json_with_retry_after(): 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(): mw, _ = _make_middleware() mw._handle_api = MagicMock() diff --git a/sapl/settings.py b/sapl/settings.py index e5586720d..8fda3a17d 100644 --- a/sapl/settings.py +++ b/sapl/settings.py @@ -436,13 +436,11 @@ RATE_LIMIT_BYPASS_PATHS = [ r'^/sessao/pauta-sessao/\d+/', ] -# 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 — daily and weekly call caps for all /api/ callers (anon and auth). +# Auth users are keyed by user pk (NAT-safe); anon callers by IP. +# Both groups share the same cap; weekly default is 7× the daily cap. +API_QUOTA_DAILY = config('API_QUOTA_DAILY', default=500, cast=int) +API_QUOTA_WEEKLY = config('API_QUOTA_WEEKLY', default=3500, cast=int) # API-specific per-minute rate limit for external (non-same-origin) anonymous calls. # Abuse writes rl:api:ip::blocked only — never rl:ip::blocked.