- 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)
208 lines
7.7 KiB
Python
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 ""
|