Browse Source

GeoIP offline build; Redis inspection tools; smart_rate/smart_key; cache KEY_PREFIX

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 <noreply@anthropic.com>
rate-limiter-2026
Edward Ribeiro 3 weeks ago
parent
commit
b61e3e5bd9
  1. 41
      docker/Dockerfile
  2. 3
      docker/geoip/.gitignore
  3. 73
      docker/geoip/update_geoip.sh
  4. 176
      docker/scripts/redis_populate_test_data.py
  5. 110
      rate-limiter-v2.md
  6. 12
      sapl/base/views.py
  7. 7
      sapl/comissoes/views.py
  8. 19
      sapl/crud/base.py
  9. 28
      sapl/materia/views.py
  10. 28
      sapl/middleware/ratelimit.py
  11. 12
      sapl/norma/views.py
  12. 16
      sapl/parlamentares/views.py
  13. 24
      sapl/protocoloadm/views.py
  14. 64
      sapl/relatorios/views.py
  15. 20
      sapl/sessao/views.py
  16. 7
      sapl/settings.py

41
docker/Dockerfile

@ -87,39 +87,24 @@ COPY --from=builder ${VENV_DIR} ${VENV_DIR}
# Código da aplicação (depois do venv para aproveitar cache) # Código da aplicação (depois do venv para aproveitar cache)
COPY . /var/interlegis/sapl/ 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 \ RUN if [ "$WITH_NGINX" = "1" ]; then \
rm -f /etc/nginx/conf.d/*; \ rm -f /etc/nginx/conf.d/*; \
cp docker/config/nginx/sapl.conf /etc/nginx/conf.d/sapl.conf; \ cp docker/config/nginx/sapl.conf /etc/nginx/conf.d/sapl.conf; \
cp docker/config/nginx/nginx.conf /etc/nginx/nginx.conf; \ cp docker/config/nginx/nginx.conf /etc/nginx/nginx.conf; \
fi if [ -f "docker/geoip/GeoLite2-ASN.mmdb" ]; then \
cp docker/geoip/GeoLite2-ASN.mmdb /etc/nginx/geoip/GeoLite2-ASN.mmdb; \
# GeoLite2-ASN database for nginx ASN-based bot blocking. echo "[geoip] GeoLite2-ASN.mmdb installed."; \
# 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."; \
else \ 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; \
fi fi

3
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

73
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=<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")"

176
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()

110
rate-limiter-v2.md

@ -53,8 +53,9 @@ graph TD
| Key type | Key schema | TTL | DB | Est. size | | Key type | Key schema | TTL | DB | Est. size |
|---|---|---|---|---| |---|---|---|---|---|
| Static cache (images/logos) | `static:{ns}:{sha256}` | 3–24 h | 0 | ~2.4 GB | | Page / view cache | `cache:{ns}:<django-generated>` | 60–600 s | 0 | ~0.5 GB |
| PDF cache (≤ 360 KB) | `file:{ns}:{sha256}` | 1 h | 0 | ~0.9 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 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 | | 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 | | User request counter | `rl:{ns}:user:{id}:reqs` | 60 s | 1 | negligible |
@ -68,8 +69,8 @@ graph TD
**Key conventions:** **Key conventions:**
- `{ns}` = Kubernetes namespace (tenant identifier). All path and user keys include it. - `{ns}` = Kubernetes namespace (tenant identifier). All path and user keys include it.
- `{user}` / `{id}` = normalized user PK: `str(user.pk).lower().strip()`. - `{user}` / `{id}` = normalized user PK: `str(user.pk).lower().strip()`.
- Django `CACHES` uses `KEY_PREFIX: "sapl"` to namespace DB0 cache keys. - Django `CACHES` uses `KEY_PREFIX: "cache:{ns}"` (e.g. `cache:sapl:`) to namespace all DB0 cache keys.
DB1 (rate limiter) uses raw keys — no prefix — for compatibility with the Lua / middleware INCR scripts. 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. - DB2 is reserved for Django Channels; allocate separately when WebSocket work resumes.
--- ---
@ -636,9 +637,9 @@ spec:
| Use case | Key prefix | DB | TTL | Notes | | Use case | Key prefix | DB | TTL | Notes |
|---|---|---|---|---| |---|---|---|---|---|
| Page / view cache | `sapl:cache:*` | 0 | 60–3600 s | `KEY_PREFIX=sapl` in Django CACHES | | Page / view cache | `cache:{ns}:*` | 0 | 60–600 s | `KEY_PREFIX=cache:{ns}` in Django CACHES |
| Static file cache (logos) | `static:{ns}:{sha256}` | 0 | 3–24 h | ns = namespace/tenant | | Static file cache (logos) | `cache:{ns}:static:{sha256}` | 0 | 3–24 h | ns = POD_NAMESPACE |
| PDF cache (≤ 360 KB) | `file:{ns}:{sha256}` | 0 | 1 h | ns required | | 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 | | 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 | | UA deny list | `rl:bot:ua:blocked` | 1 | permanent SET | Seed once after deploy |
| WebSocket / Channels | `channels:*` | 2 | session TTL | **Future — Phase 5** | | WebSocket / Channels | `channels:*` | 2 | session TTL | **Future — Phase 5** |
@ -661,7 +662,7 @@ CACHES = {
else 'django.core.cache.backends.filebased.FileBasedCache' else 'django.core.cache.backends.filebased.FileBasedCache'
), ),
'LOCATION': REDIS_URL + '/0' if _redis_ready else '/var/tmp/django_cache', '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': { '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 <namespace> 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 ## 5. Phase 2 — Rate Limiting & Bot Mitigation
**Goal**: Effective cross-pod throttling using shared Redis. **Goal**: Effective cross-pod throttling using shared Redis.

12
sapl/base/views.py

@ -48,11 +48,11 @@ from sapl.parlamentares.models import (
from sapl.protocoloadm.models import (Anexado, Protocolo) from sapl.protocoloadm.models import (Anexado, Protocolo)
from sapl.relatorios.views import (relatorio_estatisticas_acesso_normas) from sapl.relatorios.views import (relatorio_estatisticas_acesso_normas)
from sapl.sessao.models import (Bancada, SessaoPlenaria) 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, from sapl.utils import (gerar_hash_arquivo, intervalos_tem_intersecao, mail_service_configured,
SEPARADOR_HASH_PROPOSICAO, show_results_filter_set, google_recaptcha_configured, SEPARADOR_HASH_PROPOSICAO, show_results_filter_set, google_recaptcha_configured,
sapn_is_enabled, is_weak_password) 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 .forms import (AlterarSenhaForm, CasaLegislativaForm, ConfiguracoesAppForm, EstatisticasAcessoNormasForm)
from .models import AppConfig, CasaLegislativa from .models import AppConfig, CasaLegislativa
@ -68,8 +68,8 @@ class IndexView(TemplateView):
return TemplateView.get(self, request, *args, **kwargs) return TemplateView.get(self, request, *args, **kwargs)
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
method=ratelimit.UNSAFE, method=ratelimit.UNSAFE,
block=True), block=True),
name='dispatch') name='dispatch')
@ -1401,8 +1401,8 @@ class SaplSearchView(SearchView):
return context return context
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class PesquisarAuditLogView(PermissionRequiredMixin, FilterView): class PesquisarAuditLogView(PermissionRequiredMixin, FilterView):

7
sapl/comissoes/views.py

@ -29,7 +29,7 @@ from sapl.crud.base import (Crud, CrudAux, MasterDetailCrud,
from sapl.materia.models import (MateriaEmTramitacao, MateriaLegislativa, from sapl.materia.models import (MateriaEmTramitacao, MateriaLegislativa,
PautaReuniao, Tramitacao) PautaReuniao, Tramitacao)
from sapl.middleware.page_cache import AnonCachePageMixin 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 sapl.utils import show_results_filter_set
from .models import (CargoComissao, Comissao, Composicao, DocumentoAcessorio, from .models import (CargoComissao, Comissao, Composicao, DocumentoAcessorio,
@ -38,7 +38,6 @@ from .models import (CargoComissao, Comissao, Composicao, DocumentoAcessorio,
from ratelimit.decorators import ratelimit from ratelimit.decorators import ratelimit
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from ..settings import RATE_LIMITER_RATE
def pegar_url_composicao(pk): def pegar_url_composicao(pk):
@ -344,8 +343,8 @@ class RemovePautaView(PermissionRequiredMixin, CreateView):
return HttpResponseRedirect(success_url) return HttpResponseRedirect(success_url)
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class AdicionaPautaView(PermissionRequiredMixin, FilterView): class AdicionaPautaView(PermissionRequiredMixin, FilterView):

19
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.crispy_layout_mixin import SaplFormHelper
from sapl.rules import (RP_ADD, RP_CHANGE, RP_DELETE, RP_DETAIL, from sapl.rules import (RP_ADD, RP_CHANGE, RP_DELETE, RP_DETAIL,
RP_LIST) RP_LIST)
from sapl.settings import RATE_LIMITER_RATE from sapl.middleware.ratelimit import smart_key, smart_rate
from sapl.middleware.ratelimit import ratelimit_ip
from sapl.utils import normalize from sapl.utils import normalize
from ratelimit.decorators import ratelimit from ratelimit.decorators import ratelimit
@ -391,8 +390,8 @@ class CrudBaseMixin(CrispyLayoutFormMixin):
return self.model._meta.verbose_name_plural return self.model._meta.verbose_name_plural
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class CrudListView(PermissionRequiredContainerCrudMixin, ListView): class CrudListView(PermissionRequiredContainerCrudMixin, ListView):
@ -731,8 +730,8 @@ class CrudCreateView(PermissionRequiredContainerCrudMixin,
return super().form_valid(form) return super().form_valid(form)
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class CrudDetailView(PermissionRequiredContainerCrudMixin, class CrudDetailView(PermissionRequiredContainerCrudMixin,
@ -1189,8 +1188,8 @@ class MasterDetailCrud(Crud):
context['title'] = title context['title'] = title
return context return context
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class ListView(Crud.ListView): class ListView(Crud.ListView):
@ -1425,8 +1424,8 @@ class MasterDetailCrud(Crud):
else: else:
return self.resolve_url(ACTION_LIST, args=(pk,)) return self.resolve_url(ACTION_LIST, args=(pk,))
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class DetailView(Crud.DetailView): class DetailView(Crud.DetailView):

28
sapl/materia/views.py

@ -52,13 +52,13 @@ from sapl.materia.forms import (AnexadaForm, AutoriaForm, AutoriaMultiCreateForm
from sapl.norma.models import LegislacaoCitada from sapl.norma.models import LegislacaoCitada
from sapl.parlamentares.models import Legislatura from sapl.parlamentares.models import Legislatura
from sapl.protocoloadm.models import Protocolo 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, from sapl.utils import (autor_label, autor_modal, gerar_hash_arquivo, get_base_url,
get_mime_type_from_file_extension, lista_anexados, get_mime_type_from_file_extension, lista_anexados,
mail_service_configured, montar_row_autor, SEPARADOR_HASH_PROPOSICAO, mail_service_configured, montar_row_autor, SEPARADOR_HASH_PROPOSICAO,
show_results_filter_set, get_tempfile_dir, show_results_filter_set, get_tempfile_dir,
google_recaptcha_configured, MultiFormatOutputMixin) 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, from .forms import (AcessorioEmLoteFilterSet, AcompanhamentoMateriaForm,
AnexadaEmLoteFilterSet, AdicionarVariasAutoriasFilterSet, AnexadaEmLoteFilterSet, AdicionarVariasAutoriasFilterSet,
@ -136,8 +136,8 @@ def proposicao_texto(request, pk):
raise Http404 raise Http404
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class AdicionarVariasAutorias(PermissionRequiredForAppCrudMixin, FilterView): class AdicionarVariasAutorias(PermissionRequiredForAppCrudMixin, FilterView):
@ -362,8 +362,8 @@ class StatusTramitacaoCrud(CrudAux):
return reverse('sapl.materia:pesquisar_statustramitacao') return reverse('sapl.materia:pesquisar_statustramitacao')
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class PesquisarStatusTramitacaoView(FilterView): class PesquisarStatusTramitacaoView(FilterView):
@ -2014,8 +2014,8 @@ class AcompanhamentoExcluirView(TemplateView):
return HttpResponseRedirect(self.get_success_url()) return HttpResponseRedirect(self.get_success_url())
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class MateriaLegislativaPesquisaView(MultiFormatOutputMixin, FilterView): class MateriaLegislativaPesquisaView(MultiFormatOutputMixin, FilterView):
@ -2279,8 +2279,8 @@ class AcompanhamentoMateriaView(CreateView):
kwargs={'pk': self.kwargs['pk']}) kwargs={'pk': self.kwargs['pk']})
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class DocumentoAcessorioEmLoteView(PermissionRequiredMixin, FilterView): class DocumentoAcessorioEmLoteView(PermissionRequiredMixin, FilterView):
@ -2395,8 +2395,8 @@ class DocumentoAcessorioEmLoteView(PermissionRequiredMixin, FilterView):
return self.get(request, self.kwargs) return self.get(request, self.kwargs)
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class MateriaAnexadaEmLoteView(PermissionRequiredMixin, FilterView): class MateriaAnexadaEmLoteView(PermissionRequiredMixin, FilterView):
@ -2523,8 +2523,8 @@ class MateriaAnexadaEmLoteView(PermissionRequiredMixin, FilterView):
return HttpResponseRedirect(success_url) return HttpResponseRedirect(success_url)
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class PrimeiraTramitacaoEmLoteView(PermissionRequiredMixin, FilterView): class PrimeiraTramitacaoEmLoteView(PermissionRequiredMixin, FilterView):

28
sapl/middleware/ratelimit.py

@ -108,6 +108,34 @@ def ratelimit_ip(group, request):
return get_client_ip(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): def _is_suspicious_headers(request):
"""Real browsers send Accept-Language + Accept; bots frequently omit them.""" """Real browsers send Accept-Language + Accept; bots frequently omit them."""
missing = sum([ missing = sum([

12
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, from sapl.crud.base import (RP_DETAIL, RP_LIST, Crud, CrudAux,
MasterDetailCrud, make_pagination) MasterDetailCrud, make_pagination)
from sapl.materia.models import Orgao 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, \ from sapl.utils import show_results_filter_set, \
sapn_is_enabled, MultiFormatOutputMixin sapn_is_enabled, MultiFormatOutputMixin
@ -39,7 +39,7 @@ from .forms import (AnexoNormaJuridicaForm, NormaFilterSet, NormaJuridicaForm,
AutoriaNormaForm, AssuntoNormaFilterSet) AutoriaNormaForm, AssuntoNormaFilterSet)
from .models import (AnexoNormaJuridica, AssuntoNorma, NormaJuridica, NormaRelacionada, from .models import (AnexoNormaJuridica, AssuntoNorma, NormaJuridica, NormaRelacionada,
TipoNormaJuridica, TipoVinculoNormaJuridica, AutoriaNorma, NormaEstatisticas) TipoNormaJuridica, TipoVinculoNormaJuridica, AutoriaNorma, NormaEstatisticas)
from ..settings import RATE_LIMITER_RATE
# LegislacaoCitadaCrud = Crud.build(LegislacaoCitada, '') # LegislacaoCitadaCrud = Crud.build(LegislacaoCitada, '')
TipoNormaCrud = CrudAux.build( TipoNormaCrud = CrudAux.build(
@ -61,8 +61,8 @@ class AssuntoNormaCrud(CrudAux):
return reverse('sapl.norma:pesquisar_assuntonorma') return reverse('sapl.norma:pesquisar_assuntonorma')
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class PesquisarAssuntoNormaView(FilterView): class PesquisarAssuntoNormaView(FilterView):
@ -155,8 +155,8 @@ class NormaRelacionadaCrud(MasterDetailCrud):
layout_key = 'NormaRelacionadaDetail' layout_key = 'NormaRelacionadaDetail'
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class NormaPesquisaView(MultiFormatOutputMixin, FilterView): class NormaPesquisaView(MultiFormatOutputMixin, FilterView):

16
sapl/parlamentares/views.py

@ -34,7 +34,7 @@ from sapl.norma.models import AutoriaNorma, NormaJuridica
from sapl.parlamentares.apps import AppConfig from sapl.parlamentares.apps import AppConfig
from sapl.rules import SAPL_GROUP_VOTANTE from sapl.rules import SAPL_GROUP_VOTANTE
from sapl.middleware.page_cache import AnonCachePageMixin 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 sapl.utils import (parlamentares_ativos, show_results_filter_set)
from .forms import (ColigacaoFilterSet, FiliacaoForm, FrenteForm, LegislaturaForm, MandatoForm, 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 ratelimit.decorators import ratelimit
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from ..settings import RATE_LIMITER_RATE
FrenteCargoCrud = CrudAux.build(FrenteCargo, 'frente_cargo') FrenteCargoCrud = CrudAux.build(FrenteCargo, 'frente_cargo')
BlocoCargoCrud = CrudAux.build(BlocoCargo, 'bloco_cargo') BlocoCargoCrud = CrudAux.build(BlocoCargo, 'bloco_cargo')
@ -190,8 +190,8 @@ class ProposicaoParlamentarCrud(CrudBaseForListAndDetailExternalAppView):
_('Texto Eletrônico')) _('Texto Eletrônico'))
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class PesquisarParlamentarView(FilterView): class PesquisarParlamentarView(FilterView):
@ -256,8 +256,8 @@ class PesquisarParlamentarView(FilterView):
return self.render_to_response(context) return self.render_to_response(context)
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class PesquisarColigacaoView(FilterView): class PesquisarColigacaoView(FilterView):
@ -316,8 +316,8 @@ class PesquisarColigacaoView(FilterView):
return self.render_to_response(context) return self.render_to_response(context)
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class PesquisarPartidoView(FilterView): class PesquisarPartidoView(FilterView):

24
sapl/protocoloadm/views.py

@ -44,7 +44,7 @@ from sapl.protocoloadm.forms import VinculoDocAdminMateriaForm,\
from sapl.protocoloadm.models import Protocolo, DocumentoAdministrativo,\ from sapl.protocoloadm.models import Protocolo, DocumentoAdministrativo,\
VinculoDocAdminMateria VinculoDocAdminMateria
from sapl.relatorios.views import relatorio_doc_administrativos 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, from sapl.utils import (create_barcode, get_base_url,
get_mime_type_from_file_extension, lista_anexados, get_mime_type_from_file_extension, lista_anexados,
show_results_filter_set, mail_service_configured, from_date_to_datetime_utc, 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, from .models import (Anexado, AcompanhamentoDocumento, DocumentoAcessorioAdministrativo,
DocumentoAdministrativo, StatusTramitacaoAdministrativo, DocumentoAdministrativo, StatusTramitacaoAdministrativo,
TipoDocumentoAdministrativo, TramitacaoAdministrativo) TipoDocumentoAdministrativo, TramitacaoAdministrativo)
from ..settings import MEDIA_ROOT, RATE_LIMITER_RATE from ..settings import MEDIA_ROOT
from ratelimit.decorators import ratelimit from ratelimit.decorators import ratelimit
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
@ -539,8 +539,8 @@ class StatusTramitacaoAdministrativoCrud(CrudAux):
ordering = 'sigla' ordering = 'sigla'
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class ProtocoloPesquisaView(PermissionRequiredMixin, FilterView): class ProtocoloPesquisaView(PermissionRequiredMixin, FilterView):
@ -1040,8 +1040,8 @@ class ProtocoloMateriaTemplateView(PermissionRequiredMixin, TemplateView):
return context return context
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class PesquisarDocumentoAdministrativoView(DocumentoAdministrativoMixin, class PesquisarDocumentoAdministrativoView(DocumentoAdministrativoMixin,
@ -1177,8 +1177,8 @@ class AnexadoCrud(MasterDetailCrud):
return 'AnexadoDetail' return 'AnexadoDetail'
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class DocumentoAnexadoEmLoteView(PermissionRequiredMixin, FilterView): class DocumentoAnexadoEmLoteView(PermissionRequiredMixin, FilterView):
@ -1658,8 +1658,8 @@ class FichaSelecionaAdmView(PermissionRequiredMixin, FormView):
'materia/impressos/ficha_adm_pdf.html') 'materia/impressos/ficha_adm_pdf.html')
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class PrimeiraTramitacaoEmLoteAdmView(PermissionRequiredMixin, FilterView): class PrimeiraTramitacaoEmLoteAdmView(PermissionRequiredMixin, FilterView):
@ -1898,8 +1898,8 @@ class VinculoDocAdminMateriaCrud(MasterDetailCrud):
return context return context
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class VinculoDocAdminMateriaEmLoteView(PermissionRequiredMixin, FilterView): class VinculoDocAdminMateriaEmLoteView(PermissionRequiredMixin, FilterView):

64
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_oradores_explicacoes_pessoais, get_consideracoes_finais,
get_ocorrencias_da_sessao, get_assinaturas, get_ocorrencias_da_sessao, get_assinaturas,
get_correspondencias) 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.settings import STATIC_ROOT
from sapl.utils import LISTA_DE_UFS, TrocaTag, filiacao_data, create_barcode, show_results_filter_set, \ from sapl.utils import LISTA_DE_UFS, TrocaTag, filiacao_data, create_barcode, show_results_filter_set, \
num_materias_por_tipo, parlamentares_ativos, MultiFormatOutputMixin 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, from .templates import (pdf_capa_processo_gerar,
pdf_documento_administrativo_gerar, pdf_espelho_gerar, pdf_documento_administrativo_gerar, pdf_espelho_gerar,
pdf_etiqueta_protocolo_gerar, pdf_materia_gerar, pdf_etiqueta_protocolo_gerar, pdf_materia_gerar,
@ -1866,8 +1866,8 @@ class RelatorioMixin:
return self.render_to_response(context) return self.render_to_response(context)
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class RelatorioDocumentosAcessoriosView(RelatorioMixin, FilterView): class RelatorioDocumentosAcessoriosView(RelatorioMixin, FilterView):
@ -1914,8 +1914,8 @@ class RelatorioDocumentosAcessoriosView(RelatorioMixin, FilterView):
return context return context
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class RelatorioVotacoesNominaisView(RelatorioMixin, MultiFormatOutputMixin, FilterView): class RelatorioVotacoesNominaisView(RelatorioMixin, MultiFormatOutputMixin, FilterView):
@ -1987,8 +1987,8 @@ class RelatorioVotacoesNominaisView(RelatorioMixin, MultiFormatOutputMixin, Filt
return context return context
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class RelatorioAtasView(RelatorioMixin, FilterView): class RelatorioAtasView(RelatorioMixin, FilterView):
@ -2016,8 +2016,8 @@ class RelatorioAtasView(RelatorioMixin, FilterView):
return context return context
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class RelatorioPresencaSessaoView(RelatorioMixin, FilterView): class RelatorioPresencaSessaoView(RelatorioMixin, FilterView):
@ -2254,8 +2254,8 @@ class RelatorioPresencaSessaoView(RelatorioMixin, FilterView):
return context return context
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class RelatorioHistoricoTramitacaoView(RelatorioMixin, FilterView): class RelatorioHistoricoTramitacaoView(RelatorioMixin, FilterView):
@ -2315,8 +2315,8 @@ class RelatorioHistoricoTramitacaoView(RelatorioMixin, FilterView):
return context return context
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class RelatorioDataFimPrazoTramitacaoView(RelatorioMixin, FilterView): class RelatorioDataFimPrazoTramitacaoView(RelatorioMixin, FilterView):
@ -2382,8 +2382,8 @@ class RelatorioDataFimPrazoTramitacaoView(RelatorioMixin, FilterView):
return context return context
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class RelatorioReuniaoView(RelatorioMixin, FilterView): class RelatorioReuniaoView(RelatorioMixin, FilterView):
@ -2420,8 +2420,8 @@ class RelatorioReuniaoView(RelatorioMixin, FilterView):
return context return context
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class RelatorioAudienciaView(RelatorioMixin, FilterView): class RelatorioAudienciaView(RelatorioMixin, FilterView):
@ -2458,8 +2458,8 @@ class RelatorioAudienciaView(RelatorioMixin, FilterView):
return context return context
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class RelatorioMateriasTramitacaoView(RelatorioMixin, FilterView): class RelatorioMateriasTramitacaoView(RelatorioMixin, FilterView):
@ -2576,8 +2576,8 @@ class RelatorioMateriasTramitacaoView(RelatorioMixin, FilterView):
return context return context
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class RelatorioMateriasPorAnoAutorTipoView(RelatorioMixin, FilterView): class RelatorioMateriasPorAnoAutorTipoView(RelatorioMixin, FilterView):
@ -2659,8 +2659,8 @@ class RelatorioMateriasPorAnoAutorTipoView(RelatorioMixin, FilterView):
return context return context
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class RelatorioMateriasPorAutorView(RelatorioMixin, FilterView): class RelatorioMateriasPorAutorView(RelatorioMixin, FilterView):
@ -2734,8 +2734,8 @@ class RelatorioMateriaAnoAssuntoView(ListView):
return context return context
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class RelatorioNormasPublicadasMesView(RelatorioMixin, FilterView): class RelatorioNormasPublicadasMesView(RelatorioMixin, FilterView):
@ -2778,8 +2778,8 @@ class RelatorioNormasPublicadasMesView(RelatorioMixin, FilterView):
return context return context
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class RelatorioNormasVigenciaView(RelatorioMixin, FilterView): class RelatorioNormasVigenciaView(RelatorioMixin, FilterView):
@ -2846,8 +2846,8 @@ class RelatorioNormasVigenciaView(RelatorioMixin, FilterView):
return context return context
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class RelatorioHistoricoTramitacaoAdmView(RelatorioMixin, FilterView): class RelatorioHistoricoTramitacaoAdmView(RelatorioMixin, FilterView):
@ -2900,8 +2900,8 @@ class RelatorioHistoricoTramitacaoAdmView(RelatorioMixin, FilterView):
return context return context
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class RelatorioNormasPorAutorView(RelatorioMixin, FilterView): class RelatorioNormasPorAutorView(RelatorioMixin, FilterView):

20
sapl/sessao/views.py

@ -47,8 +47,8 @@ from sapl.sessao.apps import AppConfig
from sapl.sessao.forms import ExpedienteMateriaForm, OrdemDiaForm, OrdemExpedienteLeituraForm, \ from sapl.sessao.forms import ExpedienteMateriaForm, OrdemDiaForm, OrdemExpedienteLeituraForm, \
CorrespondenciaForm, CorrespondenciaEmLoteFilterSet CorrespondenciaForm, CorrespondenciaEmLoteFilterSet
from sapl.sessao.models import Correspondencia from sapl.sessao.models import Correspondencia
from sapl.settings import TIME_ZONE, RATE_LIMITER_RATE from sapl.settings import TIME_ZONE
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, remover_acentos, \ from sapl.utils import show_results_filter_set, remover_acentos, \
MultiFormatOutputMixin, PautaMultiFormatOutputMixin MultiFormatOutputMixin, PautaMultiFormatOutputMixin
@ -3798,8 +3798,8 @@ class SessaoListView(ListView):
return context return context
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class PautaSessaoView(TemplateView): class PautaSessaoView(TemplateView):
@ -3817,8 +3817,8 @@ class PautaSessaoView(TemplateView):
reverse('sapl.sessao:pauta_sessao_detail', kwargs={'pk': sessao.pk})) reverse('sapl.sessao:pauta_sessao_detail', kwargs={'pk': sessao.pk}))
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class PautaSessaoDetailView(PautaMultiFormatOutputMixin, DetailView): class PautaSessaoDetailView(PautaMultiFormatOutputMixin, DetailView):
@ -4002,8 +4002,8 @@ class PautaSessaoDetailView(PautaMultiFormatOutputMixin, DetailView):
return self.render_to_response(context) return self.render_to_response(context)
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class PesquisarSessaoPlenariaView(MultiFormatOutputMixin, FilterView): class PesquisarSessaoPlenariaView(MultiFormatOutputMixin, FilterView):
@ -5394,8 +5394,8 @@ class CorrespondenciaCrud(MasterDetailCrud):
return obj return obj
@method_decorator(ratelimit(key=ratelimit_ip, @method_decorator(ratelimit(key=smart_key,
rate=RATE_LIMITER_RATE, rate=smart_rate,
block=True), block=True),
name='dispatch') name='dispatch')
class CorrespondenciaEmLoteView(PermissionRequiredMixin, FilterView): class CorrespondenciaEmLoteView(PermissionRequiredMixin, FilterView):

7
sapl/settings.py

@ -34,7 +34,7 @@ PROJECT_DIR = Path(__file__).ancestor(2)
# SECURITY WARNING: keep the secret key used in production secret! # SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = config('SECRET_KEY', default='32jk1h412l3kjh421lkj4hlkj234') SECRET_KEY = config('SECRET_KEY', default='32jk1h412l3kjh421lkj4hlkj234')
# SECURITY WARNING: don't run with debug turned on in production! # 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' 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 / # Defaults to the machine hostname so self-hosted (bare-metal / VM /
# docker-compose) deployments work without any extra config. # docker-compose) deployments work without any extra config.
# On Kubernetes, POD_NAMESPACE is set by start.sh via the Downward API or # 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' else 'django.core.cache.backends.filebased.FileBasedCache'
), ),
'LOCATION': REDIS_URL + '/0' if _redis_ready else '/var/tmp/django_cache', 'LOCATION': REDIS_URL + '/0' if _redis_ready else '/var/tmp/django_cache',
'KEY_PREFIX': POD_NAMESPACE, 'KEY_PREFIX': f'cache:{POD_NAMESPACE}',
**( **(
{ {
'OPTIONS': { 'OPTIONS': {

Loading…
Cancel
Save