-
Notifications
You must be signed in to change notification settings - Fork 5k
feat(api): manage workspace webhooks via the public token API #9398
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
clwluvw
wants to merge
1
commit into
makeplane:preview
Choose a base branch
from
clwluvw:webhook-api-auth
base: preview
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+704
−37
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| # Copyright (c) 2023-present Plane Software, Inc. and contributors | ||
| # SPDX-License-Identifier: AGPL-3.0-only | ||
| # See the LICENSE file for details. | ||
|
|
||
| # Third party imports | ||
| from rest_framework import serializers | ||
|
|
||
| # Module imports | ||
| from .base import BaseSerializer | ||
| from plane.db.models import Webhook | ||
| from plane.db.models.webhook import validate_domain, validate_schema | ||
| from plane.utils.webhook import validate_webhook_url | ||
|
|
||
|
|
||
| class WebhookSerializer(BaseSerializer): | ||
| """Public token-API serializer for workspace webhooks. | ||
|
|
||
| Mirrors the internal app-API webhook serializer: enforces the schema/domain | ||
| validators plus the shared SSRF/disallowed-domain guard, and keeps | ||
| ``secret_key`` server-generated (read-only) so it is returned on create but | ||
| never accepted as input. | ||
| """ | ||
|
|
||
| url = serializers.URLField(validators=[validate_schema, validate_domain]) | ||
|
|
||
| def _validate_webhook_url(self, url): | ||
| """Validate a webhook URL against SSRF and disallowed-domain rules. | ||
|
|
||
| Thin adapter binding the serializer's request context to the shared | ||
| ``validate_webhook_url`` guard, mirroring the internal app-API | ||
| serializer so the SSRF/URL checks cannot drift between the two. | ||
| """ | ||
| validate_webhook_url(url, self.context.get("request")) | ||
|
|
||
| def create(self, validated_data): | ||
| url = validated_data.get("url", None) | ||
| self._validate_webhook_url(url) | ||
| return Webhook.objects.create(**validated_data) | ||
|
|
||
| def update(self, instance, validated_data): | ||
| url = validated_data.get("url", None) | ||
| if url: | ||
| self._validate_webhook_url(url) | ||
| return super().update(instance, validated_data) | ||
|
|
||
| class Meta: | ||
| model = Webhook | ||
| fields = [ | ||
| "id", | ||
| "url", | ||
| "is_active", | ||
| "secret_key", | ||
| "project", | ||
| "issue", | ||
| "module", | ||
| "cycle", | ||
| "issue_comment", | ||
| "created_at", | ||
| "updated_at", | ||
| ] | ||
| read_only_fields = [ | ||
| "id", | ||
| "workspace", | ||
| "secret_key", | ||
| "created_at", | ||
| "updated_at", | ||
| ] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| # Copyright (c) 2023-present Plane Software, Inc. and contributors | ||
| # SPDX-License-Identifier: AGPL-3.0-only | ||
| # See the LICENSE file for details. | ||
|
|
||
| from django.urls import path | ||
|
|
||
| from plane.api.views import WebhookAPIEndpoint, WebhookDetailAPIEndpoint | ||
|
|
||
| urlpatterns = [ | ||
| path( | ||
| "workspaces/<str:slug>/webhooks/", | ||
| WebhookAPIEndpoint.as_view(http_method_names=["get", "post"]), | ||
| name="webhooks", | ||
| ), | ||
| path( | ||
| "workspaces/<str:slug>/webhooks/<uuid:pk>/", | ||
| WebhookDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), | ||
| name="webhook-detail", | ||
| ), | ||
| ] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,217 @@ | ||
| # Copyright (c) 2023-present Plane Software, Inc. and contributors | ||
| # SPDX-License-Identifier: AGPL-3.0-only | ||
| # See the LICENSE file for details. | ||
|
|
||
| # Django imports | ||
| from django.db import IntegrityError | ||
|
|
||
| # Third party imports | ||
| from rest_framework import status | ||
| from rest_framework.response import Response | ||
| from drf_spectacular.utils import ( | ||
| extend_schema, | ||
| OpenApiResponse, | ||
| OpenApiRequest, | ||
| OpenApiParameter, | ||
| OpenApiTypes, | ||
| ) | ||
|
|
||
| # Module imports | ||
| from .base import BaseAPIView | ||
| from plane.api.serializers import WebhookSerializer | ||
| from plane.db.models import Webhook, Workspace | ||
| from plane.utils.permissions import WorkspaceOwnerPermission | ||
| from plane.utils.openapi import ( | ||
| WORKSPACE_SLUG_PARAMETER, | ||
| UNAUTHORIZED_RESPONSE, | ||
| FORBIDDEN_RESPONSE, | ||
| WORKSPACE_NOT_FOUND_RESPONSE, | ||
| CONFLICT_RESPONSE, | ||
| ) | ||
|
|
||
| # Fields returned to clients everywhere except the create response — the | ||
| # server-generated ``secret_key`` is intentionally withheld here and only ever | ||
| # surfaced once, in the create response, so it is not leaked on every read. | ||
| WEBHOOK_READ_FIELDS = ( | ||
| "id", | ||
| "url", | ||
| "is_active", | ||
| "created_at", | ||
| "updated_at", | ||
| "project", | ||
| "issue", | ||
| "cycle", | ||
| "module", | ||
| "issue_comment", | ||
| ) | ||
|
|
||
| WEBHOOK_PK_PARAMETER = OpenApiParameter( | ||
| name="pk", | ||
| description="Webhook ID", | ||
| required=True, | ||
| type=OpenApiTypes.UUID, | ||
| location=OpenApiParameter.PATH, | ||
| ) | ||
|
|
||
|
|
||
| class WebhookAPIEndpoint(BaseAPIView): | ||
| """Manage workspace webhooks via the token API. | ||
|
|
||
| Mirrors the internal app-API webhook endpoint: workspace-admin only, | ||
| enforces the shared URL schema/domain validators and the SSRF / | ||
| ``WEBHOOK_ALLOWED_IPS`` guard, and generates the signing ``secret_key`` | ||
| server-side (returned only in the create response). | ||
| """ | ||
|
|
||
| permission_classes = [WorkspaceOwnerPermission] | ||
| serializer_class = WebhookSerializer | ||
|
|
||
| @extend_schema( | ||
| operation_id="create_webhook", | ||
| summary="Create webhook", | ||
| description=( | ||
| "Register a webhook for the workspace. The signing `secret_key` is " | ||
| "generated server-side and returned only in this response. The target " | ||
| "`url` is validated against the SSRF/URL guards (localhost, private " | ||
| "networks and non-http(s) schemes are rejected)." | ||
| ), | ||
| tags=["Webhooks"], | ||
| parameters=[WORKSPACE_SLUG_PARAMETER], | ||
| request=OpenApiRequest(request=WebhookSerializer), | ||
| responses={ | ||
| 201: OpenApiResponse(description="Webhook created", response=WebhookSerializer), | ||
| 400: OpenApiResponse(description="Invalid or disallowed webhook URL"), | ||
| 401: UNAUTHORIZED_RESPONSE, | ||
| 403: FORBIDDEN_RESPONSE, | ||
| 404: WORKSPACE_NOT_FOUND_RESPONSE, | ||
| 409: CONFLICT_RESPONSE, | ||
| }, | ||
| ) | ||
| def post(self, request, slug): | ||
| workspace = Workspace.objects.get(slug=slug) | ||
| try: | ||
| serializer = WebhookSerializer(data=request.data, context={"request": request}) | ||
| if serializer.is_valid(): | ||
| serializer.save(workspace_id=workspace.id) | ||
| return Response(serializer.data, status=status.HTTP_201_CREATED) | ||
| return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) | ||
| except IntegrityError as e: | ||
| # Only the unique webhook-URL violation maps to 409; any other | ||
| # integrity error (FK / NOT NULL / ...) is re-raised so it is not | ||
| # masked as a duplicate-URL conflict. | ||
| if "already exists" in str(e): | ||
| return Response( | ||
| {"error": "URL already exists for the workspace"}, | ||
| status=status.HTTP_409_CONFLICT, | ||
| ) | ||
| raise | ||
|
|
||
| @extend_schema( | ||
| operation_id="list_webhooks", | ||
| summary="List webhooks", | ||
| description=("List all webhooks for the workspace. The `secret_key` is never included in these responses."), | ||
| tags=["Webhooks"], | ||
| parameters=[WORKSPACE_SLUG_PARAMETER], | ||
| responses={ | ||
| 200: OpenApiResponse(description="Webhooks", response=WebhookSerializer(many=True)), | ||
| 401: UNAUTHORIZED_RESPONSE, | ||
| 403: FORBIDDEN_RESPONSE, | ||
| 404: WORKSPACE_NOT_FOUND_RESPONSE, | ||
| }, | ||
| ) | ||
| def get(self, request, slug): | ||
| webhooks = Webhook.objects.filter(workspace__slug=slug) | ||
| serializer = WebhookSerializer(webhooks, fields=WEBHOOK_READ_FIELDS, many=True) | ||
| return Response(serializer.data, status=status.HTTP_200_OK) | ||
|
|
||
|
|
||
| class WebhookDetailAPIEndpoint(BaseAPIView): | ||
| """Retrieve, update or delete a single workspace webhook via the token API. | ||
|
|
||
| Split from ``WebhookAPIEndpoint`` so the collection ``GET`` (list) and the | ||
| detail ``GET`` (retrieve) get distinct, semantically correct OpenAPI | ||
| ``operationId``s instead of colliding on one shared handler. | ||
| """ | ||
|
|
||
| permission_classes = [WorkspaceOwnerPermission] | ||
| serializer_class = WebhookSerializer | ||
|
|
||
| @extend_schema( | ||
| operation_id="retrieve_webhook", | ||
| summary="Retrieve webhook", | ||
| description="Retrieve a single webhook by ID. The `secret_key` is never included in this response.", | ||
| tags=["Webhooks"], | ||
| parameters=[WORKSPACE_SLUG_PARAMETER, WEBHOOK_PK_PARAMETER], | ||
| responses={ | ||
| 200: OpenApiResponse(description="Webhook", response=WebhookSerializer), | ||
| 401: UNAUTHORIZED_RESPONSE, | ||
| 403: FORBIDDEN_RESPONSE, | ||
| 404: WORKSPACE_NOT_FOUND_RESPONSE, | ||
| }, | ||
| ) | ||
| def get(self, request, slug, pk): | ||
| webhook = Webhook.objects.get(workspace__slug=slug, pk=pk) | ||
| serializer = WebhookSerializer(webhook, fields=WEBHOOK_READ_FIELDS) | ||
| return Response(serializer.data, status=status.HTTP_200_OK) | ||
|
|
||
| @extend_schema( | ||
| operation_id="update_webhook", | ||
| summary="Update webhook", | ||
| description=( | ||
| "Update a webhook's target `url`, active state or entity toggles. A " | ||
| "changed `url` is re-validated against the SSRF/URL guards. The " | ||
| "`secret_key` cannot be changed here." | ||
| ), | ||
| tags=["Webhooks"], | ||
| parameters=[WORKSPACE_SLUG_PARAMETER, WEBHOOK_PK_PARAMETER], | ||
| request=OpenApiRequest(request=WebhookSerializer), | ||
| responses={ | ||
| 200: OpenApiResponse(description="Webhook updated", response=WebhookSerializer), | ||
| 400: OpenApiResponse(description="Invalid or disallowed webhook URL"), | ||
| 401: UNAUTHORIZED_RESPONSE, | ||
| 403: FORBIDDEN_RESPONSE, | ||
| 404: WORKSPACE_NOT_FOUND_RESPONSE, | ||
| 409: CONFLICT_RESPONSE, | ||
| }, | ||
| ) | ||
| def patch(self, request, slug, pk): | ||
| webhook = Webhook.objects.get(workspace__slug=slug, pk=pk) | ||
| try: | ||
| serializer = WebhookSerializer( | ||
| webhook, | ||
| data=request.data, | ||
| context={"request": request}, | ||
| partial=True, | ||
| fields=WEBHOOK_READ_FIELDS, | ||
| ) | ||
| if serializer.is_valid(): | ||
| serializer.save() | ||
| return Response(serializer.data, status=status.HTTP_200_OK) | ||
| return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) | ||
| except IntegrityError as e: | ||
| # Only the unique webhook-URL violation maps to 409; any other | ||
| # integrity error is re-raised rather than masked as a conflict. | ||
| if "already exists" in str(e): | ||
| return Response( | ||
| {"error": "URL already exists for the workspace"}, | ||
| status=status.HTTP_409_CONFLICT, | ||
| ) | ||
| raise | ||
|
|
||
| @extend_schema( | ||
| operation_id="delete_webhook", | ||
| summary="Delete webhook", | ||
| description="Delete a workspace webhook by ID.", | ||
| tags=["Webhooks"], | ||
| parameters=[WORKSPACE_SLUG_PARAMETER, WEBHOOK_PK_PARAMETER], | ||
| responses={ | ||
| 204: OpenApiResponse(description="Webhook deleted"), | ||
| 401: UNAUTHORIZED_RESPONSE, | ||
| 403: FORBIDDEN_RESPONSE, | ||
| 404: WORKSPACE_NOT_FOUND_RESPONSE, | ||
| }, | ||
| ) | ||
| def delete(self, request, slug, pk): | ||
| webhook = Webhook.objects.get(workspace__slug=slug, pk=pk) | ||
| webhook.delete() | ||
| return Response(status=status.HTTP_204_NO_CONTENT) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.