bottleneckcrm/backend/webhooks/views.py

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
)