infrasynth-backend-kit/infrasynth/api/idempotency.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

56 lines
1.9 KiB
Python

"""Idempotency-Key handling for state-mutating POSTs (``API-STANDARD.md`` §7)."""
from __future__ import annotations
import hashlib
from collections.abc import Callable
from functools import wraps
from typing import Any
from django.core.cache import cache
__all__ = ["idempotent"]
_TTL_SECONDS = 24 * 60 * 60
def _cache_key(request: Any, idempotency_key: str) -> str:
from infrasynth.tenancy.context import get_current_tenant
tenant = get_current_tenant()
tenant_part = str(tenant.pk) if tenant is not None else "anon"
fingerprint = hashlib.sha256(f"{request.method}:{request.path}".encode()).hexdigest()[:16]
return f"tenant:{tenant_part}:idempotency:{idempotency_key}:{fingerprint}"
def idempotent(view_method: Callable[..., Any]) -> Callable[..., Any]:
"""Replays the first successful response for a repeated ``Idempotency-Key``.
Views that create real-world side effects (checkout, invitation acceptance)
apply this to the action method. Without the header the request is a no-op.
"""
@wraps(view_method)
def wrapper(self: Any, request: Any, *args: Any, **kwargs: Any) -> Any:
from rest_framework.response import Response
key = request.headers.get("Idempotency-Key")
if not key:
return view_method(self, request, *args, **kwargs)
cache_key = _cache_key(request, key)
cached = cache.get(cache_key)
if cached is not None:
response = Response(cached["data"], status=cached["status"])
response["Idempotent-Replay"] = "true"
return response
response = view_method(self, request, *args, **kwargs)
if 200 <= response.status_code < 300:
try:
cache.set(cache_key, {"data": response.data, "status": response.status_code}, _TTL_SECONDS)
except Exception: # noqa: BLE001 - never fail a request over cache serialization
pass
return response
return wrapper