infrasynth-backend-kit/infrasynth/webhooks/views.py
jcv-dev a2930426b4 feat: tenant configs, live signals, and automatic permission management
- infrasynth.configs: typed multi-tenant config store (registry, service,
  secrets, cache) + public config_changed/config_reset signals and API
- emit the declared-but-dead signals (features flags/overrides, scheduler
  task_completed/task_failed, tenancy tenant_updated, audit model_changed)
  and per-model audit field exclusions
- security: permission catalog (security_permission), Django-style
  model-derived AutoPermission, PermissionRegistry, RoleAssignment,
  global-or-tenant Grant/Revoke, catalog API
- consolidate the permission surface: PermissionRegistry only (drop the
  settings dict), IsAuthenticatedAndPermitted aliases HybridPermission,
  require_permission replaced by required_permissions + require_all
- packaging: add [build-system]; add Forgejo publish workflow (.forgejo)
2026-09-29 17:06:54 -05:00

208 lines
7.7 KiB
Python

from django.utils.module_loading import import_string
from rest_framework import status, viewsets
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from infrasynth.security.permissions import IsAuthenticatedAndPermitted
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 = [IsAuthenticatedAndPermitted]
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 = [IsAuthenticatedAndPermitted]
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 = [IsAuthenticatedAndPermitted]
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 = [IsAuthenticatedAndPermitted]
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 = [IsAuthenticatedAndPermitted]
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 ""