200 lines
8.2 KiB
Python
200 lines
8.2 KiB
Python
"""Outbound webhook management. Admin-only for every method, reads included.
|
|
|
|
Routes (all under /api/webhooks/):
|
|
GET / list endpoints, plus the event catalogue
|
|
POST / create; the response carries the secret once
|
|
GET /<id>/ one endpoint
|
|
PATCH /<id>/ change url, description, events, format, is_active
|
|
DELETE /<id>/ delete it and its delivery log
|
|
POST /<id>/test/ queue a `ping` delivery
|
|
POST /<id>/rotate-secret/ replace the secret; the response carries it once
|
|
GET /<id>/deliveries/ paginated delivery log
|
|
POST /deliveries/<id>/redeliver/ send a settled delivery again, as a new row
|
|
|
|
Whoever turns an endpoint on, or changes what it sends or where (url, events,
|
|
format, the secret), becomes its creator and is audited. Otherwise an admin
|
|
could re-point a colleague's endpoint at their own server and keep receiving
|
|
the org's records after being demoted, since only endpoints a demoted admin
|
|
created are paused (webhooks/ownership.py). A description edit moves nothing.
|
|
|
|
Reads are gated too. A URL often embeds its own credential (Zapier and Slack
|
|
hook URLs are bearer secrets), and the delivery log holds copies of records a
|
|
member may not be allowed to see.
|
|
|
|
Another org's id answers 404, never 403, so ids cannot be probed.
|
|
"""
|
|
|
|
from drf_spectacular.types import OpenApiTypes
|
|
from drf_spectacular.utils import extend_schema
|
|
from rest_framework import status
|
|
from rest_framework.pagination import LimitOffsetPagination
|
|
from rest_framework.permissions import IsAuthenticated
|
|
from rest_framework.response import Response
|
|
from rest_framework.views import APIView
|
|
|
|
from common.audit_log import audit_log
|
|
from common.lookups import get_scoped_or_404
|
|
from common.permissions import HasOrgContext, IsOrgAdmin
|
|
from webhooks import events
|
|
from webhooks.emit import envelope, queue_delivery
|
|
from webhooks.models import PENDING, WebhookDelivery, WebhookEndpoint, generate_secret
|
|
from webhooks.serializers import WebhookDeliverySerializer, WebhookEndpointSerializer
|
|
|
|
MAX_ENDPOINTS_PER_ORG = 10
|
|
|
|
# Changing any of these makes the caller the endpoint's creator. See the
|
|
# module docstring.
|
|
TAKE_OVER_FIELDS = ("url", "events", "format")
|
|
|
|
|
|
def _error(message, code=status.HTTP_400_BAD_REQUEST):
|
|
return Response({"error": True, "errors": message}, status=code)
|
|
|
|
|
|
class WebhookBaseView(APIView):
|
|
permission_classes = (IsAuthenticated, HasOrgContext, IsOrgAdmin)
|
|
|
|
def get_endpoint(self, request, pk):
|
|
return get_scoped_or_404(WebhookEndpoint, pk, request.profile.org)
|
|
|
|
|
|
class WebhookListCreateView(WebhookBaseView):
|
|
@extend_schema(
|
|
tags=["Webhooks"], operation_id="webhooks_list", responses=OpenApiTypes.OBJECT
|
|
)
|
|
def get(self, request):
|
|
endpoints = WebhookEndpoint.objects.filter(
|
|
org=request.profile.org
|
|
).select_related("created_by")
|
|
return Response(
|
|
{
|
|
"endpoints": WebhookEndpointSerializer(endpoints, many=True).data,
|
|
"event_catalogue": events.catalogue_json(request.profile.org),
|
|
"limit": MAX_ENDPOINTS_PER_ORG,
|
|
}
|
|
)
|
|
|
|
@extend_schema(
|
|
tags=["Webhooks"],
|
|
request=WebhookEndpointSerializer,
|
|
responses=OpenApiTypes.OBJECT,
|
|
)
|
|
def post(self, request):
|
|
org = request.profile.org
|
|
if WebhookEndpoint.objects.filter(org=org).count() >= MAX_ENDPOINTS_PER_ORG:
|
|
return _error(
|
|
f"An organization can have at most {MAX_ENDPOINTS_PER_ORG} webhooks."
|
|
)
|
|
serializer = WebhookEndpointSerializer(data=request.data, context={"org": org})
|
|
serializer.is_valid(raise_exception=True)
|
|
endpoint = serializer.save(org=org)
|
|
# The one response that carries the full secret, besides a rotate.
|
|
return Response(
|
|
{**WebhookEndpointSerializer(endpoint).data, "secret": endpoint.secret},
|
|
status=status.HTTP_201_CREATED,
|
|
)
|
|
|
|
|
|
class WebhookDetailView(WebhookBaseView):
|
|
@extend_schema(tags=["Webhooks"], responses=WebhookEndpointSerializer)
|
|
def get(self, request, pk):
|
|
return Response(WebhookEndpointSerializer(self.get_endpoint(request, pk)).data)
|
|
|
|
@extend_schema(
|
|
tags=["Webhooks"],
|
|
request=WebhookEndpointSerializer,
|
|
responses=WebhookEndpointSerializer,
|
|
)
|
|
def patch(self, request, pk):
|
|
endpoint = self.get_endpoint(request, pk)
|
|
was_active, previous_creator_id = endpoint.is_active, endpoint.created_by_id
|
|
serializer = WebhookEndpointSerializer(
|
|
endpoint,
|
|
data=request.data,
|
|
partial=True,
|
|
context={"org": request.profile.org},
|
|
)
|
|
serializer.is_valid(raise_exception=True)
|
|
data = serializer.validated_data
|
|
changed = [
|
|
field
|
|
for field in TAKE_OVER_FIELDS
|
|
if field in data and data[field] != getattr(endpoint, field)
|
|
]
|
|
if data.get("is_active") and not was_active:
|
|
changed.append("is_active")
|
|
if changed:
|
|
serializer.save(created_by=request.user)
|
|
audit_log.webhook_taken_over(
|
|
request.user, endpoint, previous_creator_id, changed, request=request
|
|
)
|
|
else:
|
|
serializer.save()
|
|
return Response(serializer.data)
|
|
|
|
@extend_schema(tags=["Webhooks"], responses={204: None})
|
|
def delete(self, request, pk):
|
|
self.get_endpoint(request, pk).delete()
|
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
|
|
|
|
class WebhookTestView(WebhookBaseView):
|
|
@extend_schema(tags=["Webhooks"], request=None, responses=WebhookDeliverySerializer)
|
|
def post(self, request, pk):
|
|
endpoint = self.get_endpoint(request, pk)
|
|
if not endpoint.is_active:
|
|
return _error("Turn the webhook on before sending a test.")
|
|
payload = envelope(
|
|
endpoint.org_id, events.PING, {"endpoint_id": str(endpoint.id)}
|
|
)
|
|
delivery = queue_delivery(endpoint, events.PING, payload)
|
|
return Response(
|
|
WebhookDeliverySerializer(delivery).data, status=status.HTTP_202_ACCEPTED
|
|
)
|
|
|
|
|
|
class WebhookRotateSecretView(WebhookBaseView):
|
|
@extend_schema(tags=["Webhooks"], request=None, responses=OpenApiTypes.OBJECT)
|
|
def post(self, request, pk):
|
|
endpoint = self.get_endpoint(request, pk)
|
|
previous_creator_id = endpoint.created_by_id
|
|
endpoint.secret = generate_secret()
|
|
endpoint.created_by = request.user
|
|
endpoint.save(
|
|
update_fields=["secret", "created_by", "updated_by", "updated_at"]
|
|
)
|
|
audit_log.webhook_taken_over(
|
|
request.user, endpoint, previous_creator_id, ["secret"], request=request
|
|
)
|
|
return Response(
|
|
{"secret": endpoint.secret, "secret_hint": endpoint.secret_hint}
|
|
)
|
|
|
|
|
|
class WebhookDeliveryListView(WebhookBaseView):
|
|
@extend_schema(tags=["Webhooks"], responses=WebhookDeliverySerializer(many=True))
|
|
def get(self, request, pk):
|
|
endpoint = self.get_endpoint(request, pk)
|
|
queryset = WebhookDelivery.objects.filter(
|
|
org=request.profile.org, endpoint=endpoint
|
|
).order_by("-created_at")
|
|
paginator = LimitOffsetPagination()
|
|
page = paginator.paginate_queryset(queryset, request, view=self)
|
|
return paginator.get_paginated_response(
|
|
WebhookDeliverySerializer(page, many=True).data
|
|
)
|
|
|
|
|
|
class WebhookRedeliverView(WebhookBaseView):
|
|
@extend_schema(tags=["Webhooks"], request=None, responses=WebhookDeliverySerializer)
|
|
def post(self, request, pk):
|
|
original = get_scoped_or_404(WebhookDelivery, pk, request.profile.org)
|
|
if original.status == PENDING:
|
|
return _error("This delivery is still being attempted.")
|
|
endpoint = original.endpoint
|
|
if not endpoint.is_active:
|
|
return _error("Turn the webhook on before redelivering.")
|
|
delivery = queue_delivery(endpoint, original.event, original.payload)
|
|
return Response(
|
|
WebhookDeliverySerializer(delivery).data, status=status.HTTP_202_ACCEPTED
|
|
)
|