infrasynth-backend-kit/infrasynth/api/throttling.py
jcv-dev 551b42eab5 feat: production-hardening pass across the kit
Close the gaps between the documented contract (API-STANDARD, TENANCY,
ENTITLEMENTS) and the implementation, and remove committed build artifacts.

Security:
- verify + process inbound webhooks (HMAC/handler verify, size limit,
  timestamp tolerance, idempotency via InboundEvent.external_id)
- real 2FA login flow (pre-auth challenge; tokens only after verify/recovery)
- wire HybridPermission into security/audit views; add API-key rotate and
  users/<id>/permissions|roles endpoints
- tenant-scoped throttling on by default; webhook replay protection
- verify MercadoPago webhook signatures
- login brute-force guard, configurable password policy, real ALTCHA PoW

Correctness:
- apply verified billing webhooks idempotently (subscription/entitlement/
  invoice/PaymentTransaction); scheduled payment lifecycle jobs
- capture audit update diffs automatically; add audit retention purge
- working notification retries, per-channel rate limits, log retention
- pluggable virus scanner, upload-size limit, pipeline toggle
- feature rollout %/environment targeting; settings-driven registrations
- workflow guards (instance cap, route depth, self-assignment, clone on re-entry)
- wire every previously-dead INFRASYNTH_* setting; drop truly dead ones

Delivery:
- README + CHANGELOG; CI format check + coverage gate
- keep test media out of the tree; untrack .coverage, __pycache__,
  egg-info, docs/ and invoice artifacts
2026-09-24 10:41:21 -05:00

58 lines
2.1 KiB
Python

"""Tenant-scoped throttling + standard rate-limit headers (``API-STANDARD.md`` §9)."""
from __future__ import annotations
import time
from typing import Any
from rest_framework.throttling import SimpleRateThrottle
__all__ = ["TenantRateThrottle", "apply_rate_limit_headers"]
class TenantRateThrottle(SimpleRateThrottle):
"""Throttles per tenant *and* per identity, never per IP alone (``TENANCY.md`` §7).
Requires a ``DEFAULT_THROTTLE_RATES["tenant"]`` rate; when it is absent the
throttle is a no-op so the kit still runs with zero configuration.
"""
scope = "tenant"
def get_rate(self) -> str | None: # type: ignore[override]
from django.conf import settings
rates = getattr(settings, "DEFAULT_THROTTLE_RATES", {})
return rates.get(self.scope)
def get_cache_key(self, request: Any, view: Any) -> str | None:
if not self.rate:
return None
from infrasynth.tenancy.context import get_current_tenant
tenant = get_current_tenant()
tenant_part = str(tenant.pk) if tenant is not None else "anon"
ident = self.get_ident(request)
return f"tenant:{tenant_part}:ratelimit:{self.scope}:{ident}"
def allow_request(self, request: Any, view: Any) -> bool:
allowed = super().allow_request(request, view)
history = getattr(self, "history", [])
num_requests = getattr(self, "num_requests", 0)
if num_requests:
request._rate_limit = { # type: ignore[attr-defined]
"limit": num_requests,
"remaining": max(0, num_requests - len(history)),
"reset": int(history[-1]) if history else int(time.time()),
}
return allowed
def apply_rate_limit_headers(response: Any, request: Any) -> None:
"""Copies throttle state recorded on the request onto the response headers."""
info = getattr(request, "_rate_limit", None)
if not info:
return
response["X-RateLimit-Limit"] = str(info["limit"])
response["X-RateLimit-Remaining"] = str(info["remaining"])
response["X-RateLimit-Reset"] = str(info["reset"])