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 ""