Skip to content

feat(api): manage workspace webhooks via the public token API#9398

Open
clwluvw wants to merge 3 commits into
makeplane:previewfrom
clwluvw:webhook-api-auth
Open

feat(api): manage workspace webhooks via the public token API#9398
clwluvw wants to merge 3 commits into
makeplane:previewfrom
clwluvw:webhook-api-auth

Conversation

@clwluvw

@clwluvw clwluvw commented Jul 11, 2026

Copy link
Copy Markdown

Description

Add GET/POST/PATCH/DELETE /api/v1/workspaces/{slug}/webhooks/ to the public (X-Api-Key) token API, mirroring the internal app-API webhook endpoint so webhooks can be provisioned programmatically instead of only through the UI.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • Feature (non-breaking change which adds functionality)
  • Improvement (change that would cause existing functionality to not work as expected)
  • Code refactoring
  • Performance improvements
  • Documentation update

Summary by CodeRabbit

  • New Features
    • Added workspace webhook API endpoints for create, list, retrieve, partial update, and delete.
    • Webhook responses now omit server-generated secrets except on create; delivery remains signed with X-Plane-Signature.
  • Bug Fixes
    • Improved webhook URL conflict handling and tightened workspace-owner access behavior.
  • Documentation
    • Added a dev-only WEBHOOK_ALLOW_PRIVATE_URLS=1 escape hatch for private/loopback webhook targets (not for production).
  • Tests
    • Added contract and unit tests covering webhook CRUD, secret exposure rules, signing, and SSRF protections (including the dev escape hatch and redirect behavior).

Copilot AI review requested due to automatic review settings July 11, 2026 22:44
@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b46a768b-3c56-4f50-83d5-2cc797ef6d1a

📥 Commits

Reviewing files that changed from the base of the PR and between be99b37 and ca5c320.

📒 Files selected for processing (17)
  • apps/api/plane/api/serializers/__init__.py
  • apps/api/plane/api/serializers/webhook.py
  • apps/api/plane/api/urls/__init__.py
  • apps/api/plane/api/urls/webhook.py
  • apps/api/plane/api/views/__init__.py
  • apps/api/plane/api/views/webhook.py
  • apps/api/plane/app/serializers/webhook.py
  • apps/api/plane/bgtasks/webhook_task.py
  • apps/api/plane/settings/common.py
  • apps/api/plane/tests/contract/api/test_webhooks.py
  • apps/api/plane/tests/unit/bg_tasks/test_url_security.py
  • apps/api/plane/utils/ip_address.py
  • apps/api/plane/utils/url_security.py
  • apps/api/plane/utils/webhook.py
  • deployments/aio/community/variables.env
  • deployments/cli/community/docker-compose.yml
  • deployments/cli/community/variables.env
🚧 Files skipped from review as they are similar to previous changes (13)
  • apps/api/plane/api/serializers/init.py
  • deployments/cli/community/variables.env
  • apps/api/plane/api/urls/init.py
  • apps/api/plane/api/urls/webhook.py
  • apps/api/plane/api/views/init.py
  • deployments/cli/community/docker-compose.yml
  • deployments/aio/community/variables.env
  • apps/api/plane/utils/webhook.py
  • apps/api/plane/settings/common.py
  • apps/api/plane/bgtasks/webhook_task.py
  • apps/api/plane/utils/ip_address.py
  • apps/api/plane/utils/url_security.py
  • apps/api/plane/app/serializers/webhook.py

📝 Walkthrough

Walkthrough

Adds workspace webhook CRUD endpoints, shared SSRF and domain validation, owner authorization, secret-key response filtering, development-only private URL controls, delivery integration, routing, and contract tests.

Changes

Workspace webhook management

Layer / File(s) Summary
Private URL validation and delivery controls
apps/api/plane/utils/ip_address.py, apps/api/plane/utils/url_security.py, apps/api/plane/bgtasks/webhook_task.py, apps/api/plane/settings/common.py, deployments/...
Adds the development-only private URL option while preserving scheme checks, IP pinning, and redirect-hop protection.
Shared webhook URL validation
apps/api/plane/utils/webhook.py, apps/api/plane/app/serializers/webhook.py
Centralizes SSRF, allowed-host, request-host loopback, and disallowed-domain validation.
Webhook serializer contract
apps/api/plane/api/serializers/webhook.py, apps/api/plane/api/serializers/__init__.py
Adds full and read-only webhook serializers with URL validation, create/update behavior, exposed fields, and secret-key filtering.
Webhook API endpoints and routing
apps/api/plane/api/views/*, apps/api/plane/api/urls/*
Adds workspace-scoped list, retrieve, create, update, and delete operations with permissions, conflict handling, OpenAPI metadata, and routes.
API and security coverage
apps/api/plane/tests/contract/api/test_webhooks.py, apps/api/plane/tests/unit/bg_tasks/test_url_security.py
Tests authorization, URL rejection, secret handling, CRUD behavior, duplicate conflicts, signed delivery, private URL controls, and IPv6 loopback protection.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant WebhookAPIEndpoint
  participant WebhookSerializer
  participant validate_webhook_url
  participant Webhook
  Client->>WebhookAPIEndpoint: Submit workspace webhook request
  WebhookAPIEndpoint->>WebhookSerializer: Validate request data
  WebhookSerializer->>validate_webhook_url: Validate URL and request host
  validate_webhook_url-->>WebhookSerializer: Accept or reject URL
  WebhookSerializer->>Webhook: Create or update webhook
  Webhook-->>WebhookAPIEndpoint: Return persisted webhook
  WebhookAPIEndpoint-->>Client: Return serialized response
Loading

Possibly related PRs

Suggested reviewers: dheeru0198

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description covers the feature and type of change, but it omits the required Test Scenarios, Screenshots, and References sections. Add the missing template sections, especially Test Scenarios with the validation steps you ran, plus Screenshots and References if applicable.
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: public token API support for managing workspace webhooks.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
apps/api/plane/tests/contract/api/test_webhooks.py (1)

90-159: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Good create coverage — consider adding a test for is_internal/version protection.

The create tests thoroughly cover secret handling, SSRF, scheme validation, conflicts, and authorization. However, there's no test asserting that is_internal and version are read-only on create. Given that secret_key is tested for read-only enforcement (line 116-123), is_internal and version deserve the same coverage — especially since they're currently writable (see the major issue flagged on the view).

🧪 Suggested test
+    `@pytest.mark.django_db`
+    def test_create_ignores_is_internal_and_version(self, api_key_client, workspace, webhook_data):
+        """is_internal and version are server-managed, not client-settable."""
+        payload = {**webhook_data, "is_internal": True, "version": "v2"}
+        response = api_key_client.post(_webhooks_url(workspace.slug), payload, format="json")
+        assert response.status_code == status.HTTP_201_CREATED
+        webhook = Webhook.objects.get(id=response.data["id"])
+        assert webhook.is_internal is False
+        assert webhook.version == "v1"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/plane/tests/contract/api/test_webhooks.py` around lines 90 - 159,
Add a create test to TestWebhookCreateAPIEndpoint verifying that client-supplied
is_internal and version values are ignored and the response/persisted webhook
retains server-controlled defaults. Follow the existing
test_create_ignores_client_supplied_secret pattern and assert both fields are
protected.
apps/api/plane/api/views/webhook.py (1)

104-127: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Operation ID list_webhooks is used for both list and retrieve.

The get method handles both list (no pk) and retrieve (with pk) operations, but the @extend_schema operation_id is "list_webhooks" for both. drf-spectacular may auto-suffix duplicates, but the naming is misleading and could cause issues with OpenAPI client generators that expect unique operation IDs.

Consider splitting into two operations or using a more generic operation_id like list_or_retrieve_webhooks.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/plane/api/views/webhook.py` around lines 104 - 127, Update the `get`
endpoint schema so its `operation_id` accurately represents both the list and
retrieve branches, or split the schema definitions to assign distinct operation
IDs for each operation. Ensure the generated OpenAPI document uses unique,
semantically correct IDs without changing the existing response behavior.
apps/api/plane/utils/webhook.py (1)

56-65: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Disallowed-domain / allowed-host matching is case-sensitive against raw config.

hostname is normalized to lowercase (Line 50), but the comparisons against settings.WEBHOOK_ALLOWED_HOSTS (Line 56) and settings.WEBHOOK_DISALLOWED_DOMAINS (Line 64) use the raw config values. If an operator configures a disallowed domain with any uppercase (e.g. Internal.Corp), it will silently fail to match internal.corp and the block is bypassed. Normalizing the config values makes the guard robust to casing.

🛡️ Suggested normalization
-    if hostname in settings.WEBHOOK_ALLOWED_HOSTS:
+    allowed_hosts = {h.rstrip(".").lower() for h in settings.WEBHOOK_ALLOWED_HOSTS}
+    if hostname in allowed_hosts:
         return

-    disallowed_domains = list(settings.WEBHOOK_DISALLOWED_DOMAINS)
+    disallowed_domains = [d.rstrip(".").lower() for d in settings.WEBHOOK_DISALLOWED_DOMAINS]
     if request:
         request_host = request.get_host().split(":")[0].rstrip(".").lower()
         disallowed_domains.append(request_host)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/plane/utils/webhook.py` around lines 56 - 65, Normalize
WEBHOOK_ALLOWED_HOSTS and WEBHOOK_DISALLOWED_DOMAINS entries to lowercase before
comparing them with the already-normalized hostname in the webhook validation
logic. Preserve the existing exact-host and subdomain matching behavior while
ensuring mixed-case configuration values are enforced.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@apps/api/plane/api/views/webhook.py`:
- Around line 104-127: Update the `get` endpoint schema so its `operation_id`
accurately represents both the list and retrieve branches, or split the schema
definitions to assign distinct operation IDs for each operation. Ensure the
generated OpenAPI document uses unique, semantically correct IDs without
changing the existing response behavior.

In `@apps/api/plane/tests/contract/api/test_webhooks.py`:
- Around line 90-159: Add a create test to TestWebhookCreateAPIEndpoint
verifying that client-supplied is_internal and version values are ignored and
the response/persisted webhook retains server-controlled defaults. Follow the
existing test_create_ignores_client_supplied_secret pattern and assert both
fields are protected.

In `@apps/api/plane/utils/webhook.py`:
- Around line 56-65: Normalize WEBHOOK_ALLOWED_HOSTS and
WEBHOOK_DISALLOWED_DOMAINS entries to lowercase before comparing them with the
already-normalized hostname in the webhook validation logic. Preserve the
existing exact-host and subdomain matching behavior while ensuring mixed-case
configuration values are enforced.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bde45a2d-257f-4f65-ac93-e6b17e981214

📥 Commits

Reviewing files that changed from the base of the PR and between dc9d80b and 14dbb17.

📒 Files selected for processing (9)
  • apps/api/plane/api/serializers/__init__.py
  • apps/api/plane/api/serializers/webhook.py
  • apps/api/plane/api/urls/__init__.py
  • apps/api/plane/api/urls/webhook.py
  • apps/api/plane/api/views/__init__.py
  • apps/api/plane/api/views/webhook.py
  • apps/api/plane/app/serializers/webhook.py
  • apps/api/plane/tests/contract/api/test_webhooks.py
  • apps/api/plane/utils/webhook.py

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds public token-API (X-Api-Key) endpoints to manage workspace webhooks under /api/v1/workspaces/{slug}/webhooks/, mirroring the internal app API behavior while centralizing webhook URL SSRF/disallowed-domain validation in a shared utility.

Changes:

  • Introduces token-API CRUD endpoints for workspace webhooks (create/list/retrieve/update/delete), with secret_key returned only on create.
  • Extracts webhook URL validation (SSRF + disallowed-domain + loop-back guard) into plane.utils.webhook.validate_webhook_url and reuses it from both app API and token API serializers.
  • Adds contract tests covering CRUD behavior, SSRF rejection, secret_key visibility rules, and delivery signature correctness.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
apps/api/plane/utils/webhook.py New shared webhook URL validation helper used by both API surfaces.
apps/api/plane/tests/contract/api/test_webhooks.py New contract tests for token-API webhook CRUD + secret/signature guarantees.
apps/api/plane/app/serializers/webhook.py Refactors internal app serializer to call the shared validation helper.
apps/api/plane/api/views/webhook.py Adds token-API webhook endpoint implementation + OpenAPI annotations.
apps/api/plane/api/views/init.py Exposes the new webhook API view from the token-API views package.
apps/api/plane/api/urls/webhook.py Adds URL routes for list/create and detail operations.
apps/api/plane/api/urls/init.py Registers webhook URL patterns into the token-API URL set.
apps/api/plane/api/serializers/webhook.py Adds token-API webhook serializer (incl. shared URL validation).
apps/api/plane/api/serializers/init.py Exports the new token-API webhook serializer.

Comment thread apps/api/plane/utils/webhook.py Outdated
Comment thread apps/api/plane/api/views/webhook.py Outdated
Comment thread apps/api/plane/api/views/webhook.py Outdated
Comment thread apps/api/plane/api/views/webhook.py
@clwluvw
clwluvw force-pushed the webhook-api-auth branch from 14dbb17 to e34e886 Compare July 11, 2026 23:04
Copilot AI review requested due to automatic review settings July 23, 2026 20:23
@clwluvw
clwluvw requested a review from mguptahub as a code owner July 23, 2026 20:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (2)

apps/api/plane/api/views/webhook.py:150

  • OpenAPI schema for the retrieve response uses WebhookSerializer, which includes secret_key in its declared fields, but the endpoint filters it out at runtime with fields=WEBHOOK_READ_FIELDS. The schema should reflect the actual response shape so clients don’t assume secret_key is returned.
        responses={
            200: OpenApiResponse(description="Webhook", response=WebhookSerializer),
            401: UNAUTHORIZED_RESPONSE,
            403: FORBIDDEN_RESPONSE,
            404: WORKSPACE_NOT_FOUND_RESPONSE,
        },

apps/api/plane/api/views/webhook.py:175

  • OpenAPI schema for the update response uses WebhookSerializer, which includes secret_key, but the PATCH handler filters the serializer fields to WEBHOOK_READ_FIELDS (excluding secret_key). Update the schema response serializer to match the filtered response fields.
        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,
        },

Comment thread apps/api/plane/api/views/webhook.py
clwluvw added 3 commits July 25, 2026 19:41
Add GET/POST/PATCH/DELETE /api/v1/workspaces/{slug}/webhooks/ to the
public (X-Api-Key) token API, mirroring the internal app-API webhook
endpoint so webhooks can be provisioned programmatically instead of only
through the UI.

- New WebhookAPIEndpoint (workspace-admin only, via WorkspaceOwnerPermission)
  reusing the shared Webhook model.
- New public WebhookSerializer: generates secret_key server-side (returned
  only on create, withheld on list/retrieve), accepts url + the entity
  toggles (issue, issue_comment, cycle, module, project), and enforces the
  schema/domain validators plus the SSRF / WEBHOOK_ALLOWED_IPS guard.
- Extract the SSRF/disallowed-domain webhook-URL validation into a shared
  plane.utils.webhook.validate_webhook_url helper used by BOTH the app and
  public serializers, so the guard can never drift between the two surfaces.
  The app serializer keeps its _validate_webhook_url adapter (behaviour
  unchanged) and delegates to the shared helper.
- Generic HMAC delivery (bgtasks/webhook_task.py) is unchanged:
  X-Plane-Signature stays HMAC-SHA256 over the JSON payload with secret_key.
- Contract tests: create returns a usable server-generated secret, the
  secret is withheld on reads, the SSRF/non-http guards reject
  loopback/private/scheme-invalid targets, duplicate URLs 409, management is
  admin-only, and a real delivery signs the payload correctly with the
  API-issued secret.

Signed-off-by: Seena Fallah <seenafallah@gmail.com>
…private targets

Provisioning webhooks against a local docker-compose Plane is impossible
today because the SSRF guard rejects private/loopback targets — a webhook
pointing at http://host.docker.internal:<port>/ (an engine on the docker
host) is blocked at both registration and delivery.

Add a deliberately narrow, default-off instance flag that permits such
targets for local development only:

- New WEBHOOK_ALLOW_PRIVATE_URLS setting (settings/common.py), parsed with
  the house `== "1"` idiom, default off, documented with a production
  warning next to the other webhook SSRF controls.
- Thread an `allow_private` flag through the shared guard so BOTH the app
  and public token APIs pick it up in one place:
  - registration: validate_webhook_url -> validate_url (allow_private
    skips only the private-IP block via require_safe=not allow_private).
  - delivery: webhook_send_task -> pinned_fetch -> _fetch_validated_hop
    (require_safe = not (trusted or allow_private)).
- The flag ONLY lifts the private-IP block: scheme (http/https) and URL
  well-formedness are still enforced, and delivery is still pinned to the
  resolved IP (no DNS-rebinding regression).
- Default-off is byte-identical to prior behavior on every path.
- Hardening: pinned_fetch_following_redirects (OAuth avatar / link
  unfurling) explicitly drops any allow_private so the redirect path can
  never inherit the escape hatch.
- Docs: dev-only commented-out example + production warning in the aio and
  cli community variables.env; wire the var through the cli docker-compose
  x-app-env anchor so the documented flag actually reaches the container.
- Tests: default rejects a private URL, flag permits the same URL, non-http
  and malformed URLs rejected regardless of the flag, delivery skips the
  block but still pins, and the redirect path never inherits the flag.

Signed-off-by: Seena Fallah <seenafallah@gmail.com>
…n reads

The list/retrieve/update responses were annotated with WebhookSerializer,
whose declared fields include the signing `secret_key`, while the handlers
filtered it out at runtime via `fields=WEBHOOK_READ_FIELDS`. The published
schema therefore contradicted the documented guarantee that the secret is
returned only once, on create — all three read operations resolved to the
`Webhook` component, which declares `secret_key`.

Replace the runtime field filtering with a dedicated read serializer so the
response and the schema are generated from the same class and cannot drift:

- Add WebhookLiteSerializer, whose field list is derived from
  WebhookSerializer minus `secret_key`, making the exclusion structural
  rather than a hand-maintained parallel list.
- Use it for the list, retrieve and update responses (runtime + schema) and
  drop the now-unused WEBHOOK_READ_FIELDS tuple. Create is unchanged and
  still returns the secret exactly once.
- Update PATCH to validate/save with the full serializer (it carries the URL
  schema, domain and SSRF guards) and render the response with the lite one.
- Tests: assert the update response hides the secret, that a client-supplied
  secret is ignored on update, and that the read shape is exactly the full
  shape minus `secret_key`.

Generated schema now resolves list/retrieve/update to a WebhookLite
component with no `secret_key`; create still resolves to Webhook.

Signed-off-by: Seena Fallah <seenafallah@gmail.com>
Copilot AI review requested due to automatic review settings July 25, 2026 17:56
@clwluvw
clwluvw force-pushed the webhook-api-auth branch from be99b37 to ca5c320 Compare July 25, 2026 17:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated no new comments.

crewletbot pushed a commit to crewlet/plane that referenced this pull request Jul 26, 2026
Two issues surfaced only once the upstream PRs were combined:

- PR makeplane#9401's app webhook test patched
  plane.app.serializers.webhook.validate_url, the SSRF seam that existed
  when it was written. PR makeplane#9398 replaced that helper with the shared
  validate_webhook_url, so the patch target no longer resolved and both
  tests errored at setup. Point the mock at the current seam.

- PR makeplane#9401 and PR makeplane#9470 each added the page OpenAPI parameters and
  examples to plane/utils/openapi/__init__.py. Merging both left
  PAGE_ID_PARAMETER, PAGE_TYPE_PARAMETER, PAGE_CREATE_EXAMPLE,
  PAGE_UPDATE_EXAMPLE and PAGE_EXAMPLE imported twice (ruff F811). Drop
  the duplicate imports; each symbol is still exported once.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants