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
207 lines
7.6 KiB
Python
207 lines
7.6 KiB
Python
from django.utils.module_loading import import_string
|
|
from rest_framework import status, viewsets
|
|
from rest_framework.permissions import AllowAny, IsAuthenticated
|
|
from rest_framework.response import Response
|
|
|
|
from infrasynth.shared.settings_utils import get_setting
|
|
|
|
from .filters import (
|
|
InboundEndpointFilter,
|
|
InboundEventFilter,
|
|
OutboundDeliveryFilter,
|
|
OutboundEndpointFilter,
|
|
OutboundSubscriptionFilter,
|
|
)
|
|
from .models import (
|
|
InboundEndpoint,
|
|
InboundEvent,
|
|
OutboundDelivery,
|
|
OutboundEndpoint,
|
|
OutboundSubscription,
|
|
)
|
|
from .serializers import (
|
|
InboundEndpointSerializer,
|
|
InboundEventSerializer,
|
|
InboundReceiveSerializer,
|
|
OutboundDeliverySerializer,
|
|
OutboundEndpointSerializer,
|
|
OutboundSubscriptionSerializer,
|
|
)
|
|
from .signals import inbound_event_received
|
|
|
|
|
|
class OutboundEndpointViewSet(viewsets.ModelViewSet):
|
|
queryset = OutboundEndpoint.objects.all()
|
|
serializer_class = OutboundEndpointSerializer
|
|
permission_classes = [IsAuthenticated]
|
|
filterset_class = OutboundEndpointFilter
|
|
search_fields = ["name"]
|
|
|
|
def get_queryset(self):
|
|
return OutboundEndpoint.objects.all()
|
|
|
|
|
|
class OutboundSubscriptionViewSet(viewsets.ModelViewSet):
|
|
queryset = OutboundSubscription.objects.select_related("endpoint").all()
|
|
serializer_class = OutboundSubscriptionSerializer
|
|
permission_classes = [IsAuthenticated]
|
|
filterset_class = OutboundSubscriptionFilter
|
|
search_fields = ["event_name"]
|
|
|
|
def get_queryset(self):
|
|
return OutboundSubscription.objects.select_related("endpoint").all()
|
|
|
|
|
|
class OutboundDeliveryViewSet(viewsets.ReadOnlyModelViewSet):
|
|
queryset = OutboundDelivery.objects.select_related("subscription__endpoint").all()
|
|
serializer_class = OutboundDeliverySerializer
|
|
permission_classes = [IsAuthenticated]
|
|
filterset_class = OutboundDeliveryFilter
|
|
|
|
def get_queryset(self):
|
|
return OutboundDelivery.objects.select_related("subscription__endpoint").all()
|
|
|
|
|
|
class InboundEndpointViewSet(viewsets.ModelViewSet):
|
|
queryset = InboundEndpoint.objects.all()
|
|
serializer_class = InboundEndpointSerializer
|
|
permission_classes = [IsAuthenticated]
|
|
filterset_class = InboundEndpointFilter
|
|
search_fields = ["name", "slug"]
|
|
|
|
def get_queryset(self):
|
|
return InboundEndpoint.objects.all()
|
|
|
|
|
|
class InboundEventViewSet(viewsets.ReadOnlyModelViewSet):
|
|
queryset = InboundEvent.objects.select_related("endpoint").all()
|
|
serializer_class = InboundEventSerializer
|
|
permission_classes = [IsAuthenticated]
|
|
filterset_class = InboundEventFilter
|
|
|
|
def get_queryset(self):
|
|
return InboundEvent.objects.select_related("endpoint").all()
|
|
|
|
|
|
class InboundReceiveView(viewsets.ViewSet):
|
|
permission_classes = [AllowAny]
|
|
|
|
def create(self, request, slug=None):
|
|
serializer = InboundReceiveSerializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
|
|
# Unauthenticated inbound webhook: resolve the tenant from the
|
|
# tenant-scoped credential (endpoint slug + verified signature), never
|
|
# from the caller.
|
|
endpoint = InboundEndpoint.all_objects.filter(slug=slug, is_active=True).first()
|
|
if endpoint is None:
|
|
return Response({"detail": "Endpoint not found"}, status=status.HTTP_404_NOT_FOUND)
|
|
|
|
raw_body = request.body or b""
|
|
max_bytes = int(get_setting("INFRASYNTH_WEBHOOKS", "MAX_PAYLOAD_SIZE_BYTES", 1048576))
|
|
if len(raw_body) > max_bytes:
|
|
return Response(
|
|
{
|
|
"code": "VALIDATION_PAYLOAD_TOO_LARGE",
|
|
"message": "Webhook payload exceeds the size limit.",
|
|
"details": [],
|
|
},
|
|
status=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
|
)
|
|
|
|
payload = serializer.validated_data["payload"]
|
|
headers = serializer.validated_data["headers"]
|
|
# Signature/event headers normally arrive as real HTTP headers; merge the
|
|
# optional payload-level ``headers`` map on top (payload wins).
|
|
normalized_headers = {str(k): str(v) for k, v in request.headers.items()}
|
|
normalized_headers.update({str(k): str(v) for k, v in (headers or {}).items()})
|
|
event_type = get_signal_header(normalized_headers, "X-Event-Type") or "unknown"
|
|
external_id = get_signal_header(normalized_headers, "X-Event-Id") or ""
|
|
|
|
try:
|
|
handler = import_string(endpoint.handler)() if endpoint.handler else None
|
|
except (ImportError, TypeError):
|
|
handler = None
|
|
|
|
verified = self._verify(endpoint, handler, payload, normalized_headers, raw_body)
|
|
if not verified:
|
|
return Response(
|
|
{
|
|
"code": "AUTH_INVALID_SIGNATURE",
|
|
"message": "The webhook signature is missing or invalid.",
|
|
"details": [],
|
|
},
|
|
status=status.HTTP_401_UNAUTHORIZED,
|
|
)
|
|
|
|
from infrasynth.tenancy.context import tenant_context
|
|
|
|
with tenant_context(endpoint.tenant):
|
|
if external_id:
|
|
existing = InboundEvent.all_objects.filter(endpoint=endpoint, external_id=external_id).first()
|
|
if existing is not None:
|
|
data = InboundEventSerializer(existing).data
|
|
data = dict(data) if isinstance(data, dict) else data
|
|
return Response({**data, "duplicate": True}, status=status.HTTP_200_OK)
|
|
|
|
event = InboundEvent.all_objects.create(
|
|
tenant=endpoint.tenant,
|
|
endpoint=endpoint,
|
|
event_type=event_type,
|
|
external_id=external_id,
|
|
raw_payload=payload,
|
|
is_verified=True,
|
|
)
|
|
|
|
backend = get_setting("INFRASYNTH_WEBHOOKS", "INBOUND_PROCESSING_BACKEND", "sync")
|
|
from .dispatch import process_inbound_event
|
|
|
|
if backend == "celery":
|
|
process_inbound_event.delay(event.id, str(endpoint.tenant_id))
|
|
else:
|
|
process_inbound_event(event.id, str(endpoint.tenant_id))
|
|
|
|
event.refresh_from_db()
|
|
|
|
inbound_event_received.send(
|
|
sender=InboundReceiveView,
|
|
event=event,
|
|
payload=payload,
|
|
headers=normalized_headers,
|
|
)
|
|
|
|
return Response(
|
|
InboundEventSerializer(event).data,
|
|
status=status.HTTP_201_CREATED,
|
|
)
|
|
|
|
@staticmethod
|
|
def _verify(endpoint, handler, payload, headers, raw_body) -> bool:
|
|
if handler is not None:
|
|
try:
|
|
return bool(handler.verify(payload, headers, endpoint.secret, raw_body=raw_body))
|
|
except TypeError:
|
|
# Older handlers without the ``raw_body`` keyword.
|
|
pass
|
|
from .signature import verify_signature
|
|
|
|
header_name = get_setting("INFRASYNTH_WEBHOOKS", "SIGNATURE_HEADER", "X-Webhook-Signature")
|
|
signature = headers.get(header_name) or headers.get("X-Signature") or ""
|
|
if not signature:
|
|
return False
|
|
tolerance = int(get_setting("INFRASYNTH_WEBHOOKS", "INBOUND_SIGNATURE_TOLERANCE_SECONDS", 300))
|
|
return verify_signature(
|
|
endpoint.secret,
|
|
raw_body.decode("utf-8", errors="replace"),
|
|
signature,
|
|
tolerance_seconds=tolerance,
|
|
)
|
|
|
|
|
|
def get_signal_header(headers: dict, name: str) -> str:
|
|
"""Case-insensitive header lookup that preserves the original casing."""
|
|
lowered = name.lower()
|
|
for key, value in headers.items():
|
|
if key.lower() == lowered:
|
|
return value
|
|
return ""
|