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
56 lines
1.9 KiB
Python
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
|