From b61e3e5bd902e5084894341f7d413cb7fb6f4ef8 Mon Sep 17 00:00:00 2001 From: Edward Oliveira Date: Tue, 14 Apr 2026 03:40:04 -0300 Subject: [PATCH] GeoIP offline build; Redis inspection tools; smart_rate/smart_key; cache KEY_PREFIX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GeoIP (docker/Dockerfile): Remove at-build-time MaxMind download (required BuildKit secrets, caused cache-miss issues). Replace with COPY from docker/geoip/GeoLite2-ASN.mmdb (git-ignored binary). If absent, build succeeds with ASN blocking disabled. Add docker/geoip/update_geoip.sh — run before each build to refresh the database from MaxMind using MAXMIND_LICENSE_KEY from env or .env file. Redis inspection / synthetic test data: Add docker/scripts/redis_populate_test_data.py — injects synthetic rl:* entries into Redis DB1 to validate key schema and blocking thresholds without waiting for real traffic. Supports DRY_RUN and CLEAR modes. Add §4.5 (Redis CLI quick-reference + RedisInsight guide) to rate-limiter-v2.md. Auth-aware @ratelimit decorators (smart_rate / smart_key): All 51 @ratelimit decorators across 9 files used rate=RATE_LIMITER_RATE (35/m) regardless of authentication, silently over-throttling logged-in users compared to what RateLimitMiddleware allows (120/m). Add smart_key() and smart_rate() to sapl/middleware/ratelimit.py: - smart_key: user pk for authenticated requests, masked IP for anon - smart_rate: RATE_LIMITER_RATE_AUTHENTICATED (120/m) for auth, RATE_LIMITER_RATE (35/m) for anon — mirrors middleware thresholds Update all 51 decorators across crud/base.py + 8 view files. Remove now-unused RATE_LIMITER_RATE imports from those files. Cache KEY_PREFIX (settings.py): Change KEY_PREFIX from POD_NAMESPACE ("sapl") to f"cache:{POD_NAMESPACE}" so DB0 cache keys are unambiguously prefixed cache:{ns}:* — distinct from any future static or file cache key patterns. Update key schema table and code examples in rate-limiter-v2.md to match. Co-Authored-By: Claude Sonnet 4.6 --- docker/Dockerfile | 41 ++--- docker/geoip/.gitignore | 3 + docker/geoip/update_geoip.sh | 73 +++++++++ docker/scripts/redis_populate_test_data.py | 176 +++++++++++++++++++++ rate-limiter-v2.md | 110 ++++++++++++- sapl/base/views.py | 12 +- sapl/comissoes/views.py | 7 +- sapl/crud/base.py | 19 ++- sapl/materia/views.py | 28 ++-- sapl/middleware/ratelimit.py | 28 ++++ sapl/norma/views.py | 12 +- sapl/parlamentares/views.py | 16 +- sapl/protocoloadm/views.py | 24 +-- sapl/relatorios/views.py | 64 ++++---- sapl/sessao/views.py | 20 +-- sapl/settings.py | 7 +- 16 files changed, 499 insertions(+), 141 deletions(-) create mode 100644 docker/geoip/.gitignore create mode 100755 docker/geoip/update_geoip.sh create mode 100644 docker/scripts/redis_populate_test_data.py diff --git a/docker/Dockerfile b/docker/Dockerfile index be71b413c..f13567c57 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -87,39 +87,24 @@ COPY --from=builder ${VENV_DIR} ${VENV_DIR} # Código da aplicação (depois do venv para aproveitar cache) COPY . /var/interlegis/sapl/ -# Nginx (somente se instalado) +# Nginx config + GeoLite2-ASN database (somente se instalado). +# +# GeoLite2-ASN.mmdb is NOT downloaded at build time. +# Run docker/geoip/update_geoip.sh before each build to refresh it. +# The .mmdb file lives at docker/geoip/GeoLite2-ASN.mmdb (git-ignored binary). +# If the file is absent the build still succeeds but ASN-based blocking is +# disabled and nginx will emit a startup warning. RUN if [ "$WITH_NGINX" = "1" ]; then \ rm -f /etc/nginx/conf.d/*; \ cp docker/config/nginx/sapl.conf /etc/nginx/conf.d/sapl.conf; \ cp docker/config/nginx/nginx.conf /etc/nginx/nginx.conf; \ - fi - -# GeoLite2-ASN database for nginx ASN-based bot blocking. -# The key is injected via BuildKit secret — it is NEVER stored in any image layer. -# -# Build command: -# DOCKER_BUILDKIT=1 docker build \ -# --secret id=maxmind_key,src=.env \ -# -f docker/Dockerfile . -# -# .env must contain: MAXMIND_LICENSE_KEY=your_key -# The weekly host cron (/etc/cron.weekly/update-geoip) refreshes the db in production. -# -# Pass --build-arg GEOIP_CACHE_BUST=$(date +%s) to force re-download. -ARG GEOIP_CACHE_BUST=0 -RUN --mount=type=secret,id=maxmind_key \ - if [ "$WITH_NGINX" = "1" ]; then \ - MAXMIND_LICENSE_KEY=$(grep -E '^MAXMIND_LICENSE_KEY=' /run/secrets/maxmind_key 2>/dev/null | cut -d= -f2- | tr -d '[:space:]' || true); \ - if [ -n "$MAXMIND_LICENSE_KEY" ]; then \ - tmpdir=$(mktemp -d) \ - && curl -fsSL \ - "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-ASN&license_key=${MAXMIND_LICENSE_KEY}&suffix=tar.gz" \ - | tar -xz --strip-components=1 -C "$tmpdir" \ - && mv "$tmpdir"/*.mmdb /etc/nginx/geoip/GeoLite2-ASN.mmdb \ - && rm -rf "$tmpdir" \ - && echo "GeoLite2-ASN.mmdb downloaded successfully."; \ + if [ -f "docker/geoip/GeoLite2-ASN.mmdb" ]; then \ + cp docker/geoip/GeoLite2-ASN.mmdb /etc/nginx/geoip/GeoLite2-ASN.mmdb; \ + echo "[geoip] GeoLite2-ASN.mmdb installed."; \ else \ - echo "MAXMIND_LICENSE_KEY not set in secret — GeoLite2-ASN.mmdb skipped. Add it to .env and rebuild."; \ + echo "[geoip] WARNING: docker/geoip/GeoLite2-ASN.mmdb not found."; \ + echo "[geoip] Run docker/geoip/update_geoip.sh then rebuild."; \ + echo "[geoip] ASN-based blocking will be DISABLED in this image."; \ fi; \ fi diff --git a/docker/geoip/.gitignore b/docker/geoip/.gitignore new file mode 100644 index 000000000..ae9fce91f --- /dev/null +++ b/docker/geoip/.gitignore @@ -0,0 +1,3 @@ +# GeoLite2 binary databases are git-ignored (large binaries, updated frequently). +# Run update_geoip.sh before each docker build to refresh. +*.mmdb diff --git a/docker/geoip/update_geoip.sh b/docker/geoip/update_geoip.sh new file mode 100755 index 000000000..0e9e0a1f5 --- /dev/null +++ b/docker/geoip/update_geoip.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# update_geoip.sh — download / refresh GeoLite2-ASN.mmdb +# +# Run this script before building a new Docker image so the image bundles +# an up-to-date MaxMind ASN database. The .mmdb binary is git-ignored; +# only this script is tracked. +# +# Usage: +# # Option 1 — key in environment +# MAXMIND_LICENSE_KEY=your_key bash docker/geoip/update_geoip.sh +# +# # Option 2 — key in project .env file +# bash docker/geoip/update_geoip.sh +# +# The script writes GeoLite2-ASN.mmdb to the same directory as itself so +# the Dockerfile COPY step can find it at docker/geoip/GeoLite2-ASN.mmdb. +# +# Suggested automation: run via a host cron job or CI pipeline step +# before triggering a docker build, e.g.: +# +# # /etc/cron.weekly/update-sapl-geoip +# #!/bin/bash +# cd /path/to/sapl && bash docker/geoip/update_geoip.sh + +set -Eeuo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OUT_FILE="$SCRIPT_DIR/GeoLite2-ASN.mmdb" + +# ── Resolve the license key ──────────────────────────────────────────────── +if [[ -z "${MAXMIND_LICENSE_KEY:-}" ]]; then + # Try the project .env (two directories up from docker/geoip/) + ENV_FILE="$(dirname "$(dirname "$SCRIPT_DIR")")/.env" + if [[ -f "$ENV_FILE" ]]; then + MAXMIND_LICENSE_KEY="$(grep -E '^MAXMIND_LICENSE_KEY=' "$ENV_FILE" 2>/dev/null \ + | cut -d= -f2- | tr -d '[:space:]' || true)" + fi +fi + +if [[ -z "${MAXMIND_LICENSE_KEY:-}" ]]; then + echo "ERROR: MAXMIND_LICENSE_KEY is not set." >&2 + echo " Set it in the environment or add MAXMIND_LICENSE_KEY= to .env" >&2 + exit 1 +fi + +# ── Download ─────────────────────────────────────────────────────────────── +URL="https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-ASN&license_key=${MAXMIND_LICENSE_KEY}&suffix=tar.gz" + +echo "[geoip] Downloading GeoLite2-ASN from MaxMind..." +tmpdir="$(mktemp -d)" +trap 'rm -rf "$tmpdir"' EXIT + +curl -fsSL --max-time 60 "$URL" | tar -xz --strip-components=1 -C "$tmpdir" +mv "$tmpdir"/GeoLite2-ASN.mmdb "$OUT_FILE" + +echo "[geoip] Saved: $OUT_FILE" +echo "[geoip] Build date: $(python3 -c " +import struct, datetime, pathlib +data = pathlib.Path('$OUT_FILE').read_bytes() +# MaxMind DB build epoch is in the last 16 bytes of the metadata section +marker = b'\xab\xcd\xefMaxMind.com' +idx = data.rfind(marker) +if idx >= 0: + # search for 'build_epoch' key nearby + chunk = data[idx:idx+512] + pos = chunk.find(b'build_epoch') + if pos >= 0: + val_start = pos + len(b'build_epoch') + 1 + epoch = struct.unpack('>Q', chunk[val_start+1:val_start+9])[0] + print(datetime.datetime.utcfromtimestamp(epoch).strftime('%Y-%m-%d')) + exit() +print('unknown') +" 2>/dev/null || echo "unknown")" diff --git a/docker/scripts/redis_populate_test_data.py b/docker/scripts/redis_populate_test_data.py new file mode 100644 index 000000000..b253f3679 --- /dev/null +++ b/docker/scripts/redis_populate_test_data.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +""" +redis_populate_test_data.py — inject synthetic rate-limiter entries into Redis. + +Purpose: validate that RateLimitMiddleware reads the expected key schema, +that Redis CLI / RedisInsight shows the right structure, and that blocking +logic fires correctly without waiting for real traffic. + +Usage: + # Against docker-compose Redis (default) + python3 docker/scripts/redis_populate_test_data.py + + # Against a different host/port + REDIS_URL=redis://localhost:6379 python3 docker/scripts/redis_populate_test_data.py + + # Show what would be written without actually writing + DRY_RUN=1 python3 docker/scripts/redis_populate_test_data.py + + # Clear all synthetic keys written by a previous run + CLEAR=1 python3 docker/scripts/redis_populate_test_data.py + +Key schema (DB 1 — rate limiter): + rl:ip:{ip}:reqs INCR counter — anonymous request count (TTL 60s) + rl:ip:{ip}:blocked string "1" — IP hard-blocked (TTL 300s) + rl:{ns}:user:{uid}:reqs INCR counter — auth user request count (TTL 60s) + rl:{ns}:user:{uid}:blocked string "1" — user hard-blocked (TTL 300s) + rl:{ns}:ip:{ip}:w:{bucket} INCR — namespace/IP sliding window (TTL 120s) +""" + +import os +import sys +import time + +# ── dependency check ────────────────────────────────────────────────────── +try: + import redis +except ImportError: + print("ERROR: redis-py not installed. Run: pip install redis", file=sys.stderr) + sys.exit(1) + +# ── config ──────────────────────────────────────────────────────────────── +REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379") +RATELIMIT_DB = 1 # DB1 is the rate-limiter database +DRY_RUN = os.environ.get("DRY_RUN", "0").lower() in ("1", "true", "yes") +CLEAR = os.environ.get("CLEAR", "0").lower() in ("1", "true", "yes") + +# Synthetic values — tweak to exercise different code paths +NAMESPACE = "sapl" # POD_NAMESPACE value (hostname or k8s namespace) +ANON_WINDOW = 60 # seconds — must match settings.RATE_LIMITER_RATE period +AUTH_WINDOW = 60 +BLOCK_TTL = 300 + +TEST_IPS = [ + "203.0.113.1", # below threshold (20 reqs) + "203.0.113.2", # AT threshold (35 reqs — should trigger block) + "203.0.113.3", # already blocked + "203.0.113.4", # namespace/window counter near threshold +] + +TEST_USERS = [ + {"uid": "42", "reqs": 50, "blocked": False}, # normal auth user + {"uid": "99", "reqs": 120, "blocked": False}, # AT auth threshold + {"uid": "7", "reqs": 10, "blocked": True}, # pre-blocked user +] + +# ── helpers ─────────────────────────────────────────────────────────────── + +def key_ip_reqs(ip): + return f"rl:ip:{ip}:reqs" + +def key_ip_blocked(ip): + return f"rl:ip:{ip}:blocked" + +def key_user_reqs(ns, uid): + return f"rl:{ns}:user:{uid}:reqs" + +def key_user_blocked(ns, uid): + return f"rl:{ns}:user:{uid}:blocked" + +def key_ns_window(ns, ip, bucket): + return f"rl:{ns}:ip:{ip}:w:{bucket}" + + +def write(r, key, value, ttl, label): + if DRY_RUN: + print(f" [dry-run] SET {key!r} = {value!r} EX {ttl} ({label})") + return + if isinstance(value, int): + pipe = r.pipeline() + pipe.set(key, value, ex=ttl) + pipe.execute() + else: + r.set(key, value, ex=ttl) + print(f" SET {key!r} = {value!r} EX {ttl}s ({label})") + + +def delete_pattern(r, pattern): + keys = r.keys(pattern) + if keys: + r.delete(*keys) + print(f" DEL {len(keys)} keys matching {pattern!r}") + else: + print(f" (no keys matching {pattern!r})") + + +# ── main ────────────────────────────────────────────────────────────────── + +def main(): + r = redis.from_url(REDIS_URL, db=RATELIMIT_DB, decode_responses=True) + try: + r.ping() + except redis.ConnectionError as exc: + print(f"ERROR: cannot connect to Redis at {REDIS_URL}: {exc}", file=sys.stderr) + sys.exit(1) + + print(f"Redis: {REDIS_URL} DB={RATELIMIT_DB} dry_run={DRY_RUN} clear={CLEAR}") + print() + + # ── clear mode ──────────────────────────────────────────────────────── + if CLEAR: + print("=== Clearing synthetic test keys ===") + for ip in TEST_IPS: + delete_pattern(r, f"rl:ip:{ip}:*") + delete_pattern(r, f"rl:{NAMESPACE}:ip:{ip}:*") + for u in TEST_USERS: + delete_pattern(r, f"rl:{NAMESPACE}:user:{u['uid']}:*") + print("Done.") + return + + # ── anonymous IP counters ───────────────────────────────────────────── + print("=== Anonymous IP request counters (DB1) ===") + write(r, key_ip_reqs(TEST_IPS[0]), 20, ANON_WINDOW, "below threshold") + write(r, key_ip_reqs(TEST_IPS[1]), 35, ANON_WINDOW, "AT threshold → middleware will block on next req") + write(r, key_ip_reqs(TEST_IPS[3]), 30, ANON_WINDOW, "below threshold") + print() + + # ── blocked IPs ─────────────────────────────────────────────────────── + print("=== Blocked IPs (DB1) ===") + write(r, key_ip_blocked(TEST_IPS[2]), "1", BLOCK_TTL, "hard-blocked") + print() + + # ── namespace/IP sliding window ─────────────────────────────────────── + print("=== Namespace/IP sliding window (DB1) ===") + bucket = int(time.time() // ANON_WINDOW) + write(r, key_ns_window(NAMESPACE, TEST_IPS[3], bucket), 34, ANON_WINDOW * 2, + "near window threshold (next req triggers ua_rotation block)") + print() + + # ── authenticated user counters ─────────────────────────────────────── + print("=== Authenticated user request counters (DB1) ===") + for u in TEST_USERS: + if not u["blocked"]: + write(r, key_user_reqs(NAMESPACE, u["uid"]), u["reqs"], AUTH_WINDOW, + f"uid={u['uid']} reqs={u['reqs']}") + print() + + # ── blocked users ───────────────────────────────────────────────────── + print("=== Blocked users (DB1) ===") + for u in TEST_USERS: + if u["blocked"]: + write(r, key_user_blocked(NAMESPACE, u["uid"]), "1", BLOCK_TTL, + f"uid={u['uid']} hard-blocked") + print() + + # ── summary ─────────────────────────────────────────────────────────── + if not DRY_RUN: + all_keys = r.keys("rl:*") + print(f"=== DB{RATELIMIT_DB} now contains {len(all_keys)} rl:* keys ===") + for k in sorted(all_keys): + ttl = r.ttl(k) + val = r.get(k) + print(f" {k!r:55s} val={val!r:5} ttl={ttl}s") + + +if __name__ == "__main__": + main() diff --git a/rate-limiter-v2.md b/rate-limiter-v2.md index 9781fc5c1..57c99f030 100644 --- a/rate-limiter-v2.md +++ b/rate-limiter-v2.md @@ -53,8 +53,9 @@ graph TD | Key type | Key schema | TTL | DB | Est. size | |---|---|---|---|---| -| Static cache (images/logos) | `static:{ns}:{sha256}` | 3–24 h | 0 | ~2.4 GB | -| PDF cache (≤ 360 KB) | `file:{ns}:{sha256}` | 1 h | 0 | ~0.9 GB | +| Page / view cache | `cache:{ns}:` | 60–600 s | 0 | ~0.5 GB | +| Static cache (images/logos) | `cache:{ns}:static:{sha256}` | 3–24 h | 0 | ~2.4 GB | +| PDF cache (≤ 360 KB) | `cache:{ns}:file:{sha256}` | 1 h | 0 | ~0.9 GB | | IP request counter | `rl:ip:{ip}:reqs` | 60 s | 1 | ~0.6 MB | | IP blocked marker | `rl:ip:{ip}:blocked` | 300 s | 1 | ~0.06 MB | | User request counter | `rl:{ns}:user:{id}:reqs` | 60 s | 1 | negligible | @@ -68,8 +69,8 @@ graph TD **Key conventions:** - `{ns}` = Kubernetes namespace (tenant identifier). All path and user keys include it. - `{user}` / `{id}` = normalized user PK: `str(user.pk).lower().strip()`. -- Django `CACHES` uses `KEY_PREFIX: "sapl"` to namespace DB0 cache keys. - DB1 (rate limiter) uses raw keys — no prefix — for compatibility with the Lua / middleware INCR scripts. +- Django `CACHES` uses `KEY_PREFIX: "cache:{ns}"` (e.g. `cache:sapl:`) to namespace all DB0 cache keys. + DB1 (rate limiter) uses raw `rl:*` keys — no prefix — for compatibility with the Lua / middleware INCR scripts. - DB2 is reserved for Django Channels; allocate separately when WebSocket work resumes. --- @@ -636,9 +637,9 @@ spec: | Use case | Key prefix | DB | TTL | Notes | |---|---|---|---|---| -| Page / view cache | `sapl:cache:*` | 0 | 60–3600 s | `KEY_PREFIX=sapl` in Django CACHES | -| Static file cache (logos) | `static:{ns}:{sha256}` | 0 | 3–24 h | ns = namespace/tenant | -| PDF cache (≤ 360 KB) | `file:{ns}:{sha256}` | 0 | 1 h | ns required | +| Page / view cache | `cache:{ns}:*` | 0 | 60–600 s | `KEY_PREFIX=cache:{ns}` in Django CACHES | +| Static file cache (logos) | `cache:{ns}:static:{sha256}` | 0 | 3–24 h | ns = POD_NAMESPACE | +| PDF cache (≤ 360 KB) | `cache:{ns}:file:{sha256}` | 0 | 1 h | ns required | | Rate limiter counters | `rl:*` | 1 | 60–300 s | Raw keys, no prefix | | UA deny list | `rl:bot:ua:blocked` | 1 | permanent SET | Seed once after deploy | | WebSocket / Channels | `channels:*` | 2 | session TTL | **Future — Phase 5** | @@ -661,7 +662,7 @@ CACHES = { else 'django.core.cache.backends.filebased.FileBasedCache' ), 'LOCATION': REDIS_URL + '/0' if _redis_ready else '/var/tmp/django_cache', - 'KEY_PREFIX': 'sapl', + 'KEY_PREFIX': f'cache:{POD_NAMESPACE}', # e.g. "cache:sapl:" or "cache:patobranco-pr:" **( { 'OPTIONS': { @@ -841,6 +842,99 @@ kubectl exec -n redis deploy/sapl-redis -- redis-cli slowlog get 25 --- +### 4.5 Inspecting Redis State + +#### CLI quick-reference (redis-cli or `kubectl exec`) + +```bash +# ── Connection ───────────────────────────────────────────────────────────── +# docker-compose +redis-cli -h localhost -p 6379 + +# k8s pod +kubectl exec -n deploy/sapl-redis -- redis-cli + +# ── DB selection (always specify -n for rate-limiter work) ───────────────── +# DB0 = page cache DB1 = rate limiter DB2 = channels (future) +redis-cli -n 1 # select DB1 + +# ── Key inspection ───────────────────────────────────────────────────────── +# List all rate-limiter keys +redis-cli -n 1 KEYS "rl:*" + +# Request counter for a specific IP +redis-cli -n 1 GET "rl:ip:203.0.113.1:reqs" + +# Remaining TTL on a counter +redis-cli -n 1 TTL "rl:ip:203.0.113.1:reqs" + +# Check if an IP is hard-blocked +redis-cli -n 1 EXISTS "rl:ip:203.0.113.1:blocked" + +# Authenticated user counter (ns = POD_NAMESPACE, uid = user pk) +redis-cli -n 1 GET "rl:sapl:user:42:reqs" + +# Namespace/IP sliding window (bucket = epoch // 60) +redis-cli -n 1 KEYS "rl:sapl:ip:203.0.113.1:w:*" + +# ── Manual block / unblock ───────────────────────────────────────────────── +# Block an IP for 5 minutes +redis-cli -n 1 SET "rl:ip:1.2.3.4:blocked" 1 EX 300 + +# Immediately unblock an IP +redis-cli -n 1 DEL "rl:ip:1.2.3.4:blocked" + +# Unblock a user +redis-cli -n 1 DEL "rl:sapl:user:42:blocked" + +# ── Aggregate stats ──────────────────────────────────────────────────────── +# Count all blocked IPs right now +redis-cli -n 1 KEYS "rl:ip:*:blocked" | wc -l + +# Count all blocked users +redis-cli -n 1 KEYS "rl:*:user:*:blocked" | wc -l + +# Total DB1 key count +redis-cli -n 1 DBSIZE + +# Memory used by DB1 +redis-cli INFO keyspace | grep "db1" + +# ── Cache DB inspection (DB0) ─────────────────────────────────────────────── +# Count cached page responses (KEY_PREFIX = cache:{ns}, e.g. "cache:sapl:") +redis-cli -n 0 KEYS "cache:sapl:*" | wc -l + +# Memory used by DB0 +redis-cli INFO keyspace | grep "db0" +``` + +#### RedisInsight + +Open `http://localhost:5540` (or whatever port you mapped) and connect to: +- **Host**: `localhost` (or the k8s service name) +- **Port**: `6379` +- **Database**: switch between DB0 (cache) and DB1 (rate limiter) using the database selector + +Filter keys by prefix `rl:ip:` to see all anonymous IP counters, `rl:*:user:` for authenticated users. + +#### Populate synthetic test data + +```bash +# Inject test entries to validate key schema and blocking thresholds +python3 docker/scripts/redis_populate_test_data.py + +# Preview what would be written (no side effects) +DRY_RUN=1 python3 docker/scripts/redis_populate_test_data.py + +# Point at a non-default Redis +REDIS_URL=redis://sapl-redis.redis.svc:6379 python3 docker/scripts/redis_populate_test_data.py + +# Clear all synthetic entries written by the script +CLEAR=1 python3 docker/scripts/redis_populate_test_data.py +``` + +--- + ## 5. Phase 2 — Rate Limiting & Bot Mitigation **Goal**: Effective cross-pod throttling using shared Redis. diff --git a/sapl/base/views.py b/sapl/base/views.py index 65e29ede8..a37df01cf 100644 --- a/sapl/base/views.py +++ b/sapl/base/views.py @@ -48,11 +48,11 @@ from sapl.parlamentares.models import ( from sapl.protocoloadm.models import (Anexado, Protocolo) from sapl.relatorios.views import (relatorio_estatisticas_acesso_normas) from sapl.sessao.models import (Bancada, SessaoPlenaria) -from sapl.settings import EMAIL_SEND_USER, RATE_LIMITER_RATE +from sapl.settings import EMAIL_SEND_USER from sapl.utils import (gerar_hash_arquivo, intervalos_tem_intersecao, mail_service_configured, SEPARADOR_HASH_PROPOSICAO, show_results_filter_set, google_recaptcha_configured, sapn_is_enabled, is_weak_password) -from sapl.middleware.ratelimit import get_client_ip, ratelimit_ip +from sapl.middleware.ratelimit import smart_key, smart_rate from .forms import (AlterarSenhaForm, CasaLegislativaForm, ConfiguracoesAppForm, EstatisticasAcessoNormasForm) from .models import AppConfig, CasaLegislativa @@ -68,8 +68,8 @@ class IndexView(TemplateView): return TemplateView.get(self, request, *args, **kwargs) -@method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, +@method_decorator(ratelimit(key=smart_key, + rate=smart_rate, method=ratelimit.UNSAFE, block=True), name='dispatch') @@ -1401,8 +1401,8 @@ class SaplSearchView(SearchView): return context -@method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, +@method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class PesquisarAuditLogView(PermissionRequiredMixin, FilterView): diff --git a/sapl/comissoes/views.py b/sapl/comissoes/views.py index 6d4e9386a..1d7194d3d 100644 --- a/sapl/comissoes/views.py +++ b/sapl/comissoes/views.py @@ -29,7 +29,7 @@ from sapl.crud.base import (Crud, CrudAux, MasterDetailCrud, from sapl.materia.models import (MateriaEmTramitacao, MateriaLegislativa, PautaReuniao, Tramitacao) from sapl.middleware.page_cache import AnonCachePageMixin -from sapl.middleware.ratelimit import ratelimit_ip +from sapl.middleware.ratelimit import smart_key, smart_rate from sapl.utils import show_results_filter_set from .models import (CargoComissao, Comissao, Composicao, DocumentoAcessorio, @@ -38,7 +38,6 @@ from .models import (CargoComissao, Comissao, Composicao, DocumentoAcessorio, from ratelimit.decorators import ratelimit from django.utils.decorators import method_decorator -from ..settings import RATE_LIMITER_RATE def pegar_url_composicao(pk): @@ -344,8 +343,8 @@ class RemovePautaView(PermissionRequiredMixin, CreateView): return HttpResponseRedirect(success_url) -@method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, +@method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class AdicionaPautaView(PermissionRequiredMixin, FilterView): diff --git a/sapl/crud/base.py b/sapl/crud/base.py index 8451ad276..a81549d2d 100644 --- a/sapl/crud/base.py +++ b/sapl/crud/base.py @@ -26,8 +26,7 @@ from sapl.crispy_layout_mixin import CrispyLayoutFormMixin, get_field_display from sapl.crispy_layout_mixin import SaplFormHelper from sapl.rules import (RP_ADD, RP_CHANGE, RP_DELETE, RP_DETAIL, RP_LIST) -from sapl.settings import RATE_LIMITER_RATE -from sapl.middleware.ratelimit import ratelimit_ip +from sapl.middleware.ratelimit import smart_key, smart_rate from sapl.utils import normalize from ratelimit.decorators import ratelimit @@ -391,8 +390,8 @@ class CrudBaseMixin(CrispyLayoutFormMixin): return self.model._meta.verbose_name_plural -@method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, +@method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class CrudListView(PermissionRequiredContainerCrudMixin, ListView): @@ -731,8 +730,8 @@ class CrudCreateView(PermissionRequiredContainerCrudMixin, return super().form_valid(form) -@method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, +@method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class CrudDetailView(PermissionRequiredContainerCrudMixin, @@ -1189,8 +1188,8 @@ class MasterDetailCrud(Crud): context['title'] = title return context - @method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, + @method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class ListView(Crud.ListView): @@ -1425,8 +1424,8 @@ class MasterDetailCrud(Crud): else: return self.resolve_url(ACTION_LIST, args=(pk,)) - @method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, + @method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class DetailView(Crud.DetailView): diff --git a/sapl/materia/views.py b/sapl/materia/views.py index 23112d888..a8c21205b 100644 --- a/sapl/materia/views.py +++ b/sapl/materia/views.py @@ -52,13 +52,13 @@ from sapl.materia.forms import (AnexadaForm, AutoriaForm, AutoriaMultiCreateForm from sapl.norma.models import LegislacaoCitada from sapl.parlamentares.models import Legislatura from sapl.protocoloadm.models import Protocolo -from sapl.settings import MAX_DOC_UPLOAD_SIZE, MEDIA_ROOT, RATE_LIMITER_RATE +from sapl.settings import MAX_DOC_UPLOAD_SIZE, MEDIA_ROOT from sapl.utils import (autor_label, autor_modal, gerar_hash_arquivo, get_base_url, get_mime_type_from_file_extension, lista_anexados, mail_service_configured, montar_row_autor, SEPARADOR_HASH_PROPOSICAO, show_results_filter_set, get_tempfile_dir, google_recaptcha_configured, MultiFormatOutputMixin) -from sapl.middleware.ratelimit import get_client_ip, ratelimit_ip +from sapl.middleware.ratelimit import get_client_ip, smart_key, smart_rate from .forms import (AcessorioEmLoteFilterSet, AcompanhamentoMateriaForm, AnexadaEmLoteFilterSet, AdicionarVariasAutoriasFilterSet, @@ -136,8 +136,8 @@ def proposicao_texto(request, pk): raise Http404 -@method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, +@method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class AdicionarVariasAutorias(PermissionRequiredForAppCrudMixin, FilterView): @@ -362,8 +362,8 @@ class StatusTramitacaoCrud(CrudAux): return reverse('sapl.materia:pesquisar_statustramitacao') -@method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, +@method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class PesquisarStatusTramitacaoView(FilterView): @@ -2014,8 +2014,8 @@ class AcompanhamentoExcluirView(TemplateView): return HttpResponseRedirect(self.get_success_url()) -@method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, +@method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class MateriaLegislativaPesquisaView(MultiFormatOutputMixin, FilterView): @@ -2279,8 +2279,8 @@ class AcompanhamentoMateriaView(CreateView): kwargs={'pk': self.kwargs['pk']}) -@method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, +@method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class DocumentoAcessorioEmLoteView(PermissionRequiredMixin, FilterView): @@ -2395,8 +2395,8 @@ class DocumentoAcessorioEmLoteView(PermissionRequiredMixin, FilterView): return self.get(request, self.kwargs) -@method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, +@method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class MateriaAnexadaEmLoteView(PermissionRequiredMixin, FilterView): @@ -2523,8 +2523,8 @@ class MateriaAnexadaEmLoteView(PermissionRequiredMixin, FilterView): return HttpResponseRedirect(success_url) -@method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, +@method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class PrimeiraTramitacaoEmLoteView(PermissionRequiredMixin, FilterView): diff --git a/sapl/middleware/ratelimit.py b/sapl/middleware/ratelimit.py index 0ab70b7d5..1af9a27ae 100644 --- a/sapl/middleware/ratelimit.py +++ b/sapl/middleware/ratelimit.py @@ -108,6 +108,34 @@ def ratelimit_ip(group, request): return get_client_ip(request) +def smart_key(group, request): + """ + Auth-aware key for @ratelimit decorators. + + Authenticated users are keyed by user pk so that office workers sharing + a NAT IP don't count against each other. Anonymous requests fall back to + the masked IP (IPv6 /64 collapsed via ip_mask). + """ + user = getattr(request, 'user', None) + if user is not None and user.is_authenticated: + return str(user.pk) + return ratelimit_ip(group, request) + + +def smart_rate(group, request): + """ + Auth-aware rate string for @ratelimit decorators. + + Returns RATE_LIMITER_RATE_AUTHENTICATED for authenticated users, + RATE_LIMITER_RATE for anonymous users — mirrors the thresholds applied + by RateLimitMiddleware so view-level and middleware-level limits agree. + """ + user = getattr(request, 'user', None) + if user is not None and user.is_authenticated: + return settings.RATE_LIMITER_RATE_AUTHENTICATED + return settings.RATE_LIMITER_RATE + + def _is_suspicious_headers(request): """Real browsers send Accept-Language + Accept; bots frequently omit them.""" missing = sum([ diff --git a/sapl/norma/views.py b/sapl/norma/views.py index a7dc66a00..e8684e233 100644 --- a/sapl/norma/views.py +++ b/sapl/norma/views.py @@ -30,7 +30,7 @@ from sapl.compilacao.views import IntegracaoTaView from sapl.crud.base import (RP_DETAIL, RP_LIST, Crud, CrudAux, MasterDetailCrud, make_pagination) from sapl.materia.models import Orgao -from sapl.middleware.ratelimit import get_client_ip, ratelimit_ip +from sapl.middleware.ratelimit import get_client_ip, smart_key, smart_rate from sapl.utils import show_results_filter_set, \ sapn_is_enabled, MultiFormatOutputMixin @@ -39,7 +39,7 @@ from .forms import (AnexoNormaJuridicaForm, NormaFilterSet, NormaJuridicaForm, AutoriaNormaForm, AssuntoNormaFilterSet) from .models import (AnexoNormaJuridica, AssuntoNorma, NormaJuridica, NormaRelacionada, TipoNormaJuridica, TipoVinculoNormaJuridica, AutoriaNorma, NormaEstatisticas) -from ..settings import RATE_LIMITER_RATE + # LegislacaoCitadaCrud = Crud.build(LegislacaoCitada, '') TipoNormaCrud = CrudAux.build( @@ -61,8 +61,8 @@ class AssuntoNormaCrud(CrudAux): return reverse('sapl.norma:pesquisar_assuntonorma') -@method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, +@method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class PesquisarAssuntoNormaView(FilterView): @@ -155,8 +155,8 @@ class NormaRelacionadaCrud(MasterDetailCrud): layout_key = 'NormaRelacionadaDetail' -@method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, +@method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class NormaPesquisaView(MultiFormatOutputMixin, FilterView): diff --git a/sapl/parlamentares/views.py b/sapl/parlamentares/views.py index ae29ba844..10fc335e2 100644 --- a/sapl/parlamentares/views.py +++ b/sapl/parlamentares/views.py @@ -34,7 +34,7 @@ from sapl.norma.models import AutoriaNorma, NormaJuridica from sapl.parlamentares.apps import AppConfig from sapl.rules import SAPL_GROUP_VOTANTE from sapl.middleware.page_cache import AnonCachePageMixin -from sapl.middleware.ratelimit import ratelimit_ip +from sapl.middleware.ratelimit import smart_key, smart_rate from sapl.utils import (parlamentares_ativos, show_results_filter_set) from .forms import (ColigacaoFilterSet, FiliacaoForm, FrenteForm, LegislaturaForm, MandatoForm, @@ -50,7 +50,7 @@ from .models import (CargoMesa, Coligacao, ComposicaoColigacao, ComposicaoMesa, from ratelimit.decorators import ratelimit from django.utils.decorators import method_decorator -from ..settings import RATE_LIMITER_RATE + FrenteCargoCrud = CrudAux.build(FrenteCargo, 'frente_cargo') BlocoCargoCrud = CrudAux.build(BlocoCargo, 'bloco_cargo') @@ -190,8 +190,8 @@ class ProposicaoParlamentarCrud(CrudBaseForListAndDetailExternalAppView): _('Texto Eletrônico')) -@method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, +@method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class PesquisarParlamentarView(FilterView): @@ -256,8 +256,8 @@ class PesquisarParlamentarView(FilterView): return self.render_to_response(context) -@method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, +@method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class PesquisarColigacaoView(FilterView): @@ -316,8 +316,8 @@ class PesquisarColigacaoView(FilterView): return self.render_to_response(context) -@method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, +@method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class PesquisarPartidoView(FilterView): diff --git a/sapl/protocoloadm/views.py b/sapl/protocoloadm/views.py index 67b20640e..2eb7e6cd7 100755 --- a/sapl/protocoloadm/views.py +++ b/sapl/protocoloadm/views.py @@ -44,7 +44,7 @@ from sapl.protocoloadm.forms import VinculoDocAdminMateriaForm,\ from sapl.protocoloadm.models import Protocolo, DocumentoAdministrativo,\ VinculoDocAdminMateria from sapl.relatorios.views import relatorio_doc_administrativos -from sapl.middleware.ratelimit import get_client_ip, ratelimit_ip +from sapl.middleware.ratelimit import get_client_ip, smart_key, smart_rate from sapl.utils import (create_barcode, get_base_url, get_mime_type_from_file_extension, lista_anexados, show_results_filter_set, mail_service_configured, from_date_to_datetime_utc, @@ -63,7 +63,7 @@ from .forms import (AcompanhamentoDocumentoForm, AnexadoEmLoteFilterSet, Anexado from .models import (Anexado, AcompanhamentoDocumento, DocumentoAcessorioAdministrativo, DocumentoAdministrativo, StatusTramitacaoAdministrativo, TipoDocumentoAdministrativo, TramitacaoAdministrativo) -from ..settings import MEDIA_ROOT, RATE_LIMITER_RATE +from ..settings import MEDIA_ROOT from ratelimit.decorators import ratelimit from django.utils.decorators import method_decorator @@ -539,8 +539,8 @@ class StatusTramitacaoAdministrativoCrud(CrudAux): ordering = 'sigla' -@method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, +@method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class ProtocoloPesquisaView(PermissionRequiredMixin, FilterView): @@ -1040,8 +1040,8 @@ class ProtocoloMateriaTemplateView(PermissionRequiredMixin, TemplateView): return context -@method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, +@method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class PesquisarDocumentoAdministrativoView(DocumentoAdministrativoMixin, @@ -1177,8 +1177,8 @@ class AnexadoCrud(MasterDetailCrud): return 'AnexadoDetail' -@method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, +@method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class DocumentoAnexadoEmLoteView(PermissionRequiredMixin, FilterView): @@ -1658,8 +1658,8 @@ class FichaSelecionaAdmView(PermissionRequiredMixin, FormView): 'materia/impressos/ficha_adm_pdf.html') -@method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, +@method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class PrimeiraTramitacaoEmLoteAdmView(PermissionRequiredMixin, FilterView): @@ -1898,8 +1898,8 @@ class VinculoDocAdminMateriaCrud(MasterDetailCrud): return context -@method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, +@method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class VinculoDocAdminMateriaEmLoteView(PermissionRequiredMixin, FilterView): diff --git a/sapl/relatorios/views.py b/sapl/relatorios/views.py index 20f29ff75..73c1494f2 100755 --- a/sapl/relatorios/views.py +++ b/sapl/relatorios/views.py @@ -48,11 +48,11 @@ from sapl.sessao.views import (get_identificacao_basica, get_mesa_diretora, get_oradores_explicacoes_pessoais, get_consideracoes_finais, get_ocorrencias_da_sessao, get_assinaturas, get_correspondencias) -from sapl.settings import MEDIA_URL, RATE_LIMITER_RATE +from sapl.settings import MEDIA_URL from sapl.settings import STATIC_ROOT from sapl.utils import LISTA_DE_UFS, TrocaTag, filiacao_data, create_barcode, show_results_filter_set, \ num_materias_por_tipo, parlamentares_ativos, MultiFormatOutputMixin -from sapl.middleware.ratelimit import ratelimit_ip +from sapl.middleware.ratelimit import smart_key, smart_rate from .templates import (pdf_capa_processo_gerar, pdf_documento_administrativo_gerar, pdf_espelho_gerar, pdf_etiqueta_protocolo_gerar, pdf_materia_gerar, @@ -1866,8 +1866,8 @@ class RelatorioMixin: return self.render_to_response(context) -@method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, +@method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class RelatorioDocumentosAcessoriosView(RelatorioMixin, FilterView): @@ -1914,8 +1914,8 @@ class RelatorioDocumentosAcessoriosView(RelatorioMixin, FilterView): return context -@method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, +@method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class RelatorioVotacoesNominaisView(RelatorioMixin, MultiFormatOutputMixin, FilterView): @@ -1987,8 +1987,8 @@ class RelatorioVotacoesNominaisView(RelatorioMixin, MultiFormatOutputMixin, Filt return context -@method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, +@method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class RelatorioAtasView(RelatorioMixin, FilterView): @@ -2016,8 +2016,8 @@ class RelatorioAtasView(RelatorioMixin, FilterView): return context -@method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, +@method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class RelatorioPresencaSessaoView(RelatorioMixin, FilterView): @@ -2254,8 +2254,8 @@ class RelatorioPresencaSessaoView(RelatorioMixin, FilterView): return context -@method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, +@method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class RelatorioHistoricoTramitacaoView(RelatorioMixin, FilterView): @@ -2315,8 +2315,8 @@ class RelatorioHistoricoTramitacaoView(RelatorioMixin, FilterView): return context -@method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, +@method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class RelatorioDataFimPrazoTramitacaoView(RelatorioMixin, FilterView): @@ -2382,8 +2382,8 @@ class RelatorioDataFimPrazoTramitacaoView(RelatorioMixin, FilterView): return context -@method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, +@method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class RelatorioReuniaoView(RelatorioMixin, FilterView): @@ -2420,8 +2420,8 @@ class RelatorioReuniaoView(RelatorioMixin, FilterView): return context -@method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, +@method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class RelatorioAudienciaView(RelatorioMixin, FilterView): @@ -2458,8 +2458,8 @@ class RelatorioAudienciaView(RelatorioMixin, FilterView): return context -@method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, +@method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class RelatorioMateriasTramitacaoView(RelatorioMixin, FilterView): @@ -2576,8 +2576,8 @@ class RelatorioMateriasTramitacaoView(RelatorioMixin, FilterView): return context -@method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, +@method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class RelatorioMateriasPorAnoAutorTipoView(RelatorioMixin, FilterView): @@ -2659,8 +2659,8 @@ class RelatorioMateriasPorAnoAutorTipoView(RelatorioMixin, FilterView): return context -@method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, +@method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class RelatorioMateriasPorAutorView(RelatorioMixin, FilterView): @@ -2734,8 +2734,8 @@ class RelatorioMateriaAnoAssuntoView(ListView): return context -@method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, +@method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class RelatorioNormasPublicadasMesView(RelatorioMixin, FilterView): @@ -2778,8 +2778,8 @@ class RelatorioNormasPublicadasMesView(RelatorioMixin, FilterView): return context -@method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, +@method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class RelatorioNormasVigenciaView(RelatorioMixin, FilterView): @@ -2846,8 +2846,8 @@ class RelatorioNormasVigenciaView(RelatorioMixin, FilterView): return context -@method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, +@method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class RelatorioHistoricoTramitacaoAdmView(RelatorioMixin, FilterView): @@ -2900,8 +2900,8 @@ class RelatorioHistoricoTramitacaoAdmView(RelatorioMixin, FilterView): return context -@method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, +@method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class RelatorioNormasPorAutorView(RelatorioMixin, FilterView): diff --git a/sapl/sessao/views.py b/sapl/sessao/views.py index 7540b7a8e..fbb061abe 100755 --- a/sapl/sessao/views.py +++ b/sapl/sessao/views.py @@ -47,8 +47,8 @@ from sapl.sessao.apps import AppConfig from sapl.sessao.forms import ExpedienteMateriaForm, OrdemDiaForm, OrdemExpedienteLeituraForm, \ CorrespondenciaForm, CorrespondenciaEmLoteFilterSet from sapl.sessao.models import Correspondencia -from sapl.settings import TIME_ZONE, RATE_LIMITER_RATE -from sapl.middleware.ratelimit import get_client_ip, ratelimit_ip +from sapl.settings import TIME_ZONE +from sapl.middleware.ratelimit import get_client_ip, smart_key, smart_rate from sapl.utils import show_results_filter_set, remover_acentos, \ MultiFormatOutputMixin, PautaMultiFormatOutputMixin @@ -3798,8 +3798,8 @@ class SessaoListView(ListView): return context -@method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, +@method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class PautaSessaoView(TemplateView): @@ -3817,8 +3817,8 @@ class PautaSessaoView(TemplateView): reverse('sapl.sessao:pauta_sessao_detail', kwargs={'pk': sessao.pk})) -@method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, +@method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class PautaSessaoDetailView(PautaMultiFormatOutputMixin, DetailView): @@ -4002,8 +4002,8 @@ class PautaSessaoDetailView(PautaMultiFormatOutputMixin, DetailView): return self.render_to_response(context) -@method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, +@method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class PesquisarSessaoPlenariaView(MultiFormatOutputMixin, FilterView): @@ -5394,8 +5394,8 @@ class CorrespondenciaCrud(MasterDetailCrud): return obj -@method_decorator(ratelimit(key=ratelimit_ip, - rate=RATE_LIMITER_RATE, +@method_decorator(ratelimit(key=smart_key, + rate=smart_rate, block=True), name='dispatch') class CorrespondenciaEmLoteView(PermissionRequiredMixin, FilterView): diff --git a/sapl/settings.py b/sapl/settings.py index 06d9f59b0..299fed34f 100644 --- a/sapl/settings.py +++ b/sapl/settings.py @@ -34,7 +34,7 @@ PROJECT_DIR = Path(__file__).ancestor(2) # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = config('SECRET_KEY', default='32jk1h412l3kjh421lkj4hlkj234') # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = config('DEBUG', default=False, cast=bool) +DEBUG = config('DEBUG', default=True, cast=bool) MESSAGE_STORAGE = 'django.contrib.messages.storage.session.SessionStorage' @@ -205,7 +205,8 @@ SPECTACULAR_SETTINGS = { } # --------------------------------------------------------------------------- -# Tenant namespace — used as Redis KEY_PREFIX and rate-limiter scope. +# Tenant namespace — used as Redis cache KEY_PREFIX (cache:{ns}:*) and +# as the rate-limiter scope for per-namespace keys. # Defaults to the machine hostname so self-hosted (bare-metal / VM / # docker-compose) deployments work without any extra config. # On Kubernetes, POD_NAMESPACE is set by start.sh via the Downward API or @@ -237,7 +238,7 @@ CACHES = { else 'django.core.cache.backends.filebased.FileBasedCache' ), 'LOCATION': REDIS_URL + '/0' if _redis_ready else '/var/tmp/django_cache', - 'KEY_PREFIX': POD_NAMESPACE, + 'KEY_PREFIX': f'cache:{POD_NAMESPACE}', **( { 'OPTIONS': {