From 4e01f327a1a4fcab1fd5e8251c07bfc459cf3b23 Mon Sep 17 00:00:00 2001 From: Iason Andriopoulos Date: Tue, 7 Jul 2026 14:16:00 +0300 Subject: [PATCH 01/18] Support webhook integrations --- src/zenml/cli/__init__.py | 1 + src/zenml/cli/webhook_integration.py | 163 ++++++++++ src/zenml/client.py | 191 +++++++++++ src/zenml/constants.py | 2 + src/zenml/enums.py | 7 + src/zenml/models/__init__.py | 33 +- .../models/v2/core/webhook_integration.py | 195 +++++++++++ src/zenml/webhooks/__init__.py | 16 + src/zenml/webhooks/adapters.py | 172 ++++++++++ src/zenml/zen_server/rbac/models.py | 1 + src/zenml/zen_server/rbac/utils.py | 4 + .../routers/webhook_integration_endpoints.py | 302 ++++++++++++++++++ src/zenml/zen_server/zen_server_api.py | 3 + .../7c0d9e4a1b2f_add_webhook_integrations.py | 95 ++++++ src/zenml/zen_stores/rest_zen_store.py | 111 +++++++ src/zenml/zen_stores/schemas/__init__.py | 4 + .../zen_stores/schemas/project_schemas.py | 5 + .../schemas/webhook_integration_schemas.py | 171 ++++++++++ src/zenml/zen_stores/sql_zen_store.py | 283 ++++++++++++++++ src/zenml/zen_stores/zen_store_interface.py | 92 ++++++ 20 files changed, 1850 insertions(+), 1 deletion(-) create mode 100644 src/zenml/cli/webhook_integration.py create mode 100644 src/zenml/models/v2/core/webhook_integration.py create mode 100644 src/zenml/webhooks/__init__.py create mode 100644 src/zenml/webhooks/adapters.py create mode 100644 src/zenml/zen_server/routers/webhook_integration_endpoints.py create mode 100644 src/zenml/zen_stores/migrations/versions/7c0d9e4a1b2f_add_webhook_integrations.py create mode 100644 src/zenml/zen_stores/schemas/webhook_integration_schemas.py diff --git a/src/zenml/cli/__init__.py b/src/zenml/cli/__init__.py index acf4e16dc69..e1a0fdab9f2 100644 --- a/src/zenml/cli/__init__.py +++ b/src/zenml/cli/__init__.py @@ -2591,3 +2591,4 @@ def my_pipeline(...): from zenml.cli.project import * # noqa from zenml.cli.tag import * # noqa from zenml.cli.trigger import * # noqa +from zenml.cli.webhook_integration import * # noqa diff --git a/src/zenml/cli/webhook_integration.py b/src/zenml/cli/webhook_integration.py new file mode 100644 index 00000000000..e58d6a63c85 --- /dev/null +++ b/src/zenml/cli/webhook_integration.py @@ -0,0 +1,163 @@ +# Copyright (c) ZenML GmbH 2026. All Rights Reserved. +"""CLI commands for webhook integrations.""" + +from typing import Any + +import click + +from zenml.cli import utils as cli_utils +from zenml.cli.cli import TagGroup, cli +from zenml.cli.utils import OutputFormat, list_options +from zenml.client import Client +from zenml.console import console +from zenml.enums import CliCategories, WebhookType +from zenml.models import WebhookIntegrationFilter + + +@cli.group( + "webhook-integration", cls=TagGroup, tag=CliCategories.MANAGEMENT_TOOLS +) +def webhook_integration() -> None: + """Manage external webhook intake endpoints.""" + + +@webhook_integration.command("create") +@click.argument("name", type=str) +@click.option( + "--type", + "webhook_type", + type=click.Choice([value.value for value in WebhookType]), + required=True, +) +@click.option("--secret", type=str, default=None) +@click.option("--inactive", is_flag=True, default=False) +def create_webhook_integration( + name: str, + webhook_type: str, + secret: str | None, + inactive: bool, +) -> None: + """Create a webhook integration. + + Args: + name: The integration name. + webhook_type: The webhook provider type. + secret: An optional user-supplied signing secret. + inactive: Whether to create the integration inactive. + """ + result = Client().create_webhook_integration( + name=name, + webhook_type=WebhookType(webhook_type), + active=not inactive, + secret=secret, + ) + cli_utils.print_pydantic_model( + title=f"Webhook integration '{name}'", + model=result.integration, + ) + if result.secret is not None: + cli_utils.declare( + f"Signing secret: {result.secret.get_secret_value()}" + ) + + +@webhook_integration.command("describe") +@click.argument("name_or_id", type=str) +def describe_webhook_integration(name_or_id: str) -> None: + """Describe a webhook integration. + + Args: + name_or_id: The integration name or ID. + """ + integration = Client().get_webhook_integration(name_or_id) + cli_utils.print_pydantic_model( + title=f"Webhook integration '{integration.name}'", + model=integration, + ) + cli_utils.declare(f"Endpoint path: {integration.endpoint_path}") + + +@webhook_integration.command("list") +@list_options( + WebhookIntegrationFilter, + default_columns=["id", "name", "webhook_type", "active", "project"], +) +def list_webhook_integrations( + columns: str, output_format: OutputFormat, **kwargs: Any +) -> None: + """List webhook integrations. + + Args: + columns: The columns to display. + output_format: The output format. + **kwargs: The webhook integration filters. + """ + with console.status("Listing webhook integrations...\n"): + integrations = Client().list_webhook_integrations(**kwargs) + cli_utils.print_page( + integrations, + columns, + output_format, + empty_message="No webhook integrations found for this filter.", + ) + + +@webhook_integration.command("update") +@click.argument("name_or_id", type=str) +@click.option("--name", "new_name", type=str, default=None) +@click.option( + "--active/--inactive", "active", default=None, help="Set active state." +) +def update_webhook_integration( + name_or_id: str, new_name: str | None, active: bool | None +) -> None: + """Update a webhook integration. + + Args: + name_or_id: The integration name or ID. + new_name: The new integration name. + active: The new active state. + """ + integration = Client().update_webhook_integration( + name_id_or_prefix=name_or_id, name=new_name, active=active + ) + cli_utils.print_pydantic_model( + title=f"Webhook integration '{integration.name}'", + model=integration, + ) + + +@webhook_integration.command("rotate-secret") +@click.argument("name_or_id", type=str) +@click.option("--secret", type=str, default=None) +def rotate_webhook_integration_secret( + name_or_id: str, secret: str | None +) -> None: + """Rotate a webhook integration signing secret. + + Args: + name_or_id: The integration name or ID. + secret: An optional user-supplied replacement secret. + """ + result = Client().rotate_webhook_integration_secret( + name_id_or_prefix=name_or_id, secret=secret + ) + cli_utils.declare(f"Signing secret: {result.secret.get_secret_value()}") + + +@webhook_integration.command("delete") +@click.argument("name_or_id", type=str) +@click.option("--yes", "confirmed", is_flag=True) +def delete_webhook_integration(name_or_id: str, confirmed: bool) -> None: + """Delete a webhook integration. + + Args: + name_or_id: The integration name or ID. + confirmed: Whether to skip the confirmation prompt. + """ + if not confirmed and not cli_utils.confirmation( + f"Delete webhook integration `{name_or_id}`?" + ): + return + Client().delete_webhook_integration(name_or_id) + cli_utils.declare(f"Deleted webhook integration `{name_or_id}`.") diff --git a/src/zenml/client.py b/src/zenml/client.py index 5505b7a71cf..6b2035f6cc6 100644 --- a/src/zenml/client.py +++ b/src/zenml/client.py @@ -78,6 +78,7 @@ TriggerRunConcurrency, TriggerType, VisualizationResourceTypes, + WebhookType, ) from zenml.exceptions import ( AuthorizationException, @@ -215,6 +216,13 @@ UserRequest, UserResponse, UserUpdate, + WebhookIntegrationCreateResponse, + WebhookIntegrationFilter, + WebhookIntegrationRequest, + WebhookIntegrationResponse, + WebhookIntegrationSecretRequest, + WebhookIntegrationSecretResponse, + WebhookIntegrationUpdate, ) from zenml.models.v2.base.filter import ( DatetimeFilterOption, @@ -6832,6 +6840,189 @@ def delete_code_repository( ) self.zen_store.delete_code_repository(code_repository_id=repo.id) + # ------------------------- Webhook integrations ------------------------- + + def create_webhook_integration( + self, + name: str, + webhook_type: WebhookType, + active: bool = True, + secret: str | None = None, + ) -> WebhookIntegrationCreateResponse: + """Create a webhook integration in the active project. + + Args: + name: The integration name. + webhook_type: The webhook provider type. + active: Whether the integration accepts events immediately. + secret: An optional user-supplied signing secret. + + Returns: + The created integration and any generated signing secret. + """ + return self.zen_store.create_webhook_integration( + WebhookIntegrationRequest( + project=self.active_project.id, + name=name, + webhook_type=webhook_type, + active=active, + secret=secret, + ) + ) + + def get_webhook_integration( + self, + name_id_or_prefix: str | UUID, + allow_name_prefix_match: bool = True, + project: str | UUID | None = None, + hydrate: bool = True, + ) -> WebhookIntegrationResponse: + """Get a webhook integration by name, ID, or prefix. + + Args: + name_id_or_prefix: The integration name, ID, or ID prefix. + allow_name_prefix_match: Whether to allow name prefix matching. + project: The project name or ID. + hydrate: Whether to include intake statistics. + + Returns: + The webhook integration. + """ + return self._get_entity_by_id_or_name_or_prefix( + get_method=self.zen_store.get_webhook_integration, + list_method=self.list_webhook_integrations, + name_id_or_prefix=name_id_or_prefix, + allow_name_prefix_match=allow_name_prefix_match, + hydrate=hydrate, + project=project, + ) + + def list_webhook_integrations( + self, + sort_by: str = "created", + page: int = PAGINATION_STARTING_PAGE, + size: int = PAGE_SIZE_DEFAULT, + logical_operator: LogicalOperators = LogicalOperators.AND, + id: UUIDFilterOption = None, + created: DatetimeFilterOption = None, + updated: DatetimeFilterOption = None, + name: StringFilterOption = None, + project: str | UUID | None = None, + user: UUIDFilterOption = None, + webhook_type: EnumFilterOption[WebhookType] = None, + active: bool | None = None, + hydrate: bool = False, + ) -> Page[WebhookIntegrationResponse]: + """List webhook integrations. + + Args: + sort_by: The field used to sort results. + page: The page index. + size: The maximum page size. + logical_operator: The operator used to combine filters. + id: Filter by integration ID. + created: Filter by creation time. + updated: Filter by update time. + name: Filter by integration name. + project: Filter by project name or ID. + user: Filter by creating user. + webhook_type: Filter by webhook provider type. + active: Filter by active state. + hydrate: Whether to include intake statistics. + + Returns: + A page of webhook integrations. + """ + filter_model = WebhookIntegrationFilter( + sort_by=sort_by, + page=page, + size=size, + logical_operator=logical_operator, + id=id, + created=created, + updated=updated, + name=name, + project=project or self.active_project.id, + user=user, + webhook_type=webhook_type, + active=active, + ) + return self.zen_store.list_webhook_integrations( + filter_model=filter_model, hydrate=hydrate + ) + + def update_webhook_integration( + self, + name_id_or_prefix: str | UUID, + name: str | None = None, + active: bool | None = None, + project: str | UUID | None = None, + ) -> WebhookIntegrationResponse: + """Update a webhook integration. + + Args: + name_id_or_prefix: The integration name, ID, or ID prefix. + name: The new integration name. + active: The new active state. + project: The project name or ID. + + Returns: + The updated webhook integration. + """ + integration = self.get_webhook_integration( + name_id_or_prefix=name_id_or_prefix, + allow_name_prefix_match=False, + project=project, + ) + return self.zen_store.update_webhook_integration( + integration_id=integration.id, + update=WebhookIntegrationUpdate(name=name, active=active), + ) + + def delete_webhook_integration( + self, + name_id_or_prefix: str | UUID, + project: str | UUID | None = None, + ) -> None: + """Delete a webhook integration. + + Args: + name_id_or_prefix: The integration name, ID, or ID prefix. + project: The project name or ID. + """ + integration = self.get_webhook_integration( + name_id_or_prefix=name_id_or_prefix, + allow_name_prefix_match=False, + project=project, + ) + self.zen_store.delete_webhook_integration(integration.id) + + def rotate_webhook_integration_secret( + self, + name_id_or_prefix: str | UUID, + secret: str | None = None, + project: str | UUID | None = None, + ) -> WebhookIntegrationSecretResponse: + """Rotate a webhook integration signing secret. + + Args: + name_id_or_prefix: The integration name, ID, or ID prefix. + secret: An optional user-supplied replacement secret. + project: The project name or ID. + + Returns: + The newly active signing secret. + """ + integration = self.get_webhook_integration( + name_id_or_prefix=name_id_or_prefix, + allow_name_prefix_match=False, + project=project, + ) + return self.zen_store.rotate_webhook_integration_secret( + integration_id=integration.id, + request=WebhookIntegrationSecretRequest(secret=secret), + ) + # --------------------------- Service Connectors --------------------------- def create_service_connector( diff --git a/src/zenml/constants.py b/src/zenml/constants.py index f4b15be4b8c..f924556d716 100644 --- a/src/zenml/constants.py +++ b/src/zenml/constants.py @@ -544,6 +544,8 @@ def handle_float_env_var(var: str, default: float = 0.0) -> float: TAGS = "/tags" TAG_RESOURCES = "/tag_resources" TRIGGERS = "/triggers" +WEBHOOK_INTEGRATIONS = "/webhook_integrations" +WEBHOOKS = "/webhooks" TRIGGER_SNAPSHOT_DISPATCH_STATE = "/dispatch_state" ONBOARDING_STATE = "/onboarding_state" USERS = "/users" diff --git a/src/zenml/enums.py b/src/zenml/enums.py index 831de6c97fc..9dfbcb7c05c 100644 --- a/src/zenml/enums.py +++ b/src/zenml/enums.py @@ -692,6 +692,13 @@ class TriggerRunConcurrency(StrEnum): SUBMIT = "submit" +class WebhookType(StrEnum): + """Supported webhook integration provider types.""" + + GITHUB = "github" + CUSTOM = "custom" + + class ContainerEngineType(StrEnum): """Container engine types.""" diff --git a/src/zenml/models/__init__.py b/src/zenml/models/__init__.py index c354691aca7..feb5e679d15 100644 --- a/src/zenml/models/__init__.py +++ b/src/zenml/models/__init__.py @@ -381,9 +381,9 @@ StepRunUpdate, ) from zenml.models.v2.core.stream_event import ( - StreamEvent, StreamBatchRequest, StreamBatchResponse, + StreamEvent, ) from zenml.models.v2.core.tag import ( TagFilter, @@ -432,6 +432,20 @@ UserResponseMetadata, UserUpdate, ) +from zenml.models.v2.core.webhook_integration import ( + WebhookEventStatsUpdate, + WebhookIntegrationCreateResponse, + WebhookIntegrationFilter, + WebhookIntegrationRequest, + WebhookIntegrationResponse, + WebhookIntegrationResponseBody, + WebhookIntegrationResponseMetadata, + WebhookIntegrationResponseResources, + WebhookIntegrationSecretRequest, + WebhookIntegrationSecretResponse, + WebhookIntegrationStats, + WebhookIntegrationUpdate, +) from zenml.models.v2.misc.auth_models import ( OAuthDeviceAuthorizationRequest, OAuthDeviceAuthorizationResponse, @@ -643,6 +657,11 @@ PlatformEventTriggerUpdate.model_rebuild() PlatformEventTriggerResponse.model_rebuild() PlatformEventTriggerResponseBody.model_rebuild() +WebhookIntegrationResponseBody.model_rebuild() +WebhookIntegrationStats.model_rebuild() +WebhookIntegrationResponseMetadata.model_rebuild() +WebhookIntegrationResponseResources.model_rebuild() +WebhookIntegrationResponse.model_rebuild() __all__ = [ @@ -1027,4 +1046,16 @@ "TRIGGER_RETURN_TYPE_UNION", "SourceEntity", "TriggerExecutionInfo", + "WebhookEventStatsUpdate", + "WebhookIntegrationCreateResponse", + "WebhookIntegrationFilter", + "WebhookIntegrationRequest", + "WebhookIntegrationResponse", + "WebhookIntegrationResponseBody", + "WebhookIntegrationResponseMetadata", + "WebhookIntegrationResponseResources", + "WebhookIntegrationSecretRequest", + "WebhookIntegrationSecretResponse", + "WebhookIntegrationStats", + "WebhookIntegrationUpdate", ] diff --git a/src/zenml/models/v2/core/webhook_integration.py b/src/zenml/models/v2/core/webhook_integration.py new file mode 100644 index 00000000000..1bd8e8a21ef --- /dev/null +++ b/src/zenml/models/v2/core/webhook_integration.py @@ -0,0 +1,195 @@ +# Copyright (c) ZenML GmbH 2026. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Models for project-scoped webhook integrations.""" + +from datetime import datetime +from typing import ClassVar + +from pydantic import Field, model_validator + +from zenml.constants import STR_FIELD_MAX_LENGTH +from zenml.enums import WebhookType +from zenml.models.v2.base.base import BaseUpdate, BaseZenModel +from zenml.models.v2.base.filter import EnumFilterOption, StringFilterOption +from zenml.models.v2.base.scoped import ( + ProjectScopedFilter, + ProjectScopedRequest, + ProjectScopedResponse, + ProjectScopedResponseBody, + ProjectScopedResponseMetadata, + ProjectScopedResponseResources, +) +from zenml.utils.secret_utils import PlainSerializedSecretStr + + +class WebhookIntegrationRequest(ProjectScopedRequest): + """Request model for creating a webhook integration.""" + + name: str = Field(max_length=STR_FIELD_MAX_LENGTH) + webhook_type: WebhookType + active: bool = True + secret: PlainSerializedSecretStr | None = Field( + default=None, + description="Optional signing secret. One is generated when omitted.", + ) + + +class WebhookIntegrationUpdate(BaseUpdate): + """Request model for updating a webhook integration.""" + + name: str | None = Field(default=None, max_length=STR_FIELD_MAX_LENGTH) + active: bool | None = None + + +class WebhookIntegrationResponseBody(ProjectScopedResponseBody): + """Response body for a webhook integration.""" + + webhook_type: WebhookType + active: bool + endpoint_path: str + + +class WebhookIntegrationStats(BaseZenModel): + """Intake statistics for a webhook integration.""" + + received_count: int = 0 + accepted_count: int = 0 + auth_failed_count: int = 0 + invalid_payload_count: int = 0 + last_received_at: datetime | None = None + last_accepted_at: datetime | None = None + last_error_at: datetime | None = None + last_error_summary: str | None = None + + +class WebhookIntegrationResponseMetadata(ProjectScopedResponseMetadata): + """Response metadata for a webhook integration.""" + + stats: WebhookIntegrationStats = Field( + default_factory=WebhookIntegrationStats + ) + + +class WebhookIntegrationResponseResources(ProjectScopedResponseResources): + """Resources associated with a webhook integration.""" + + +class WebhookIntegrationResponse( + ProjectScopedResponse[ + WebhookIntegrationResponseBody, + WebhookIntegrationResponseMetadata, + WebhookIntegrationResponseResources, + ] +): + """Response model for a webhook integration.""" + + name: str = Field(max_length=STR_FIELD_MAX_LENGTH) + + def get_hydrated_version(self) -> "WebhookIntegrationResponse": + """Return the hydrated webhook integration. + + Returns: + The hydrated webhook integration. + """ + from zenml.client import Client + + return Client().zen_store.get_webhook_integration(self.id) + + @property + def webhook_type(self) -> WebhookType: + """Return the webhook provider type. + + Returns: + The webhook provider type. + """ + return self.get_body().webhook_type + + @property + def active(self) -> bool: + """Return whether the integration accepts events. + + Returns: + Whether the integration accepts events. + """ + return self.get_body().active + + @property + def endpoint_path(self) -> str: + """Return the provider-facing event endpoint path. + + Returns: + The provider-facing event endpoint path. + """ + return self.get_body().endpoint_path + + +class WebhookIntegrationFilter(ProjectScopedFilter): + """Filter model for webhook integrations.""" + + name: StringFilterOption = None + webhook_type: EnumFilterOption[WebhookType] = None + API_SINGLE_INPUT_PARAMS: ClassVar[list[str]] = [ + *ProjectScopedFilter.API_SINGLE_INPUT_PARAMS, + "active", + ] + + active: bool | None = None + + +class WebhookIntegrationCreateResponse(BaseZenModel): + """Creation result with a generated secret when applicable.""" + + integration: WebhookIntegrationResponse + secret: PlainSerializedSecretStr | None = None + + +class WebhookIntegrationSecretRequest(BaseZenModel): + """Request model for rotating a webhook integration secret.""" + + secret: PlainSerializedSecretStr | None = None + + +class WebhookIntegrationSecretResponse(BaseZenModel): + """One-time response containing a newly active signing secret.""" + + secret: PlainSerializedSecretStr + + +class WebhookEventStatsUpdate(BaseZenModel): + """Atomic intake statistics update.""" + + accepted: bool = False + auth_failed: bool = False + invalid_payload: bool = False + error_summary: str | None = Field(default=None, max_length=4096) + + @model_validator(mode="after") + def validate_single_outcome(self) -> "WebhookEventStatsUpdate": + """Validate that exactly one terminal intake outcome is selected. + + Returns: + The validated statistics update. + + Raises: + ValueError: If the update does not contain exactly one outcome or + an accepted outcome contains an error. + """ + outcomes = (self.accepted, self.auth_failed, self.invalid_payload) + if sum(outcomes) != 1: + raise ValueError("Exactly one webhook intake outcome is required.") + if self.accepted and self.error_summary is not None: + raise ValueError( + "Accepted webhook events cannot include an error." + ) + return self diff --git a/src/zenml/webhooks/__init__.py b/src/zenml/webhooks/__init__.py new file mode 100644 index 00000000000..b0e93477886 --- /dev/null +++ b/src/zenml/webhooks/__init__.py @@ -0,0 +1,16 @@ +# Copyright (c) ZenML GmbH 2026. All Rights Reserved. +"""Webhook provider authentication and payload validation.""" + +from zenml.webhooks.adapters import ( + WebhookAuthenticationError, + WebhookEvent, + WebhookPayloadError, + get_webhook_adapter, +) + +__all__ = [ + "WebhookAuthenticationError", + "WebhookEvent", + "WebhookPayloadError", + "get_webhook_adapter", +] diff --git a/src/zenml/webhooks/adapters.py b/src/zenml/webhooks/adapters.py new file mode 100644 index 00000000000..8ccf8518f7b --- /dev/null +++ b/src/zenml/webhooks/adapters.py @@ -0,0 +1,172 @@ +# Copyright (c) ZenML GmbH 2026. All Rights Reserved. +"""Provider adapters for webhook authentication and basic validation.""" + +import hashlib +import hmac +import json +from abc import ABC, abstractmethod +from collections.abc import Mapping +from typing import Any + +from pydantic import BaseModel + +from zenml.enums import WebhookType + + +class WebhookAuthenticationError(ValueError): + """Raised when a webhook signature cannot be authenticated.""" + + +class WebhookPayloadError(ValueError): + """Raised when a webhook payload fails fundamental validation.""" + + +class WebhookEvent(BaseModel): + """Authenticated provider event produced by an intake adapter.""" + + webhook_type: WebhookType + event_type: str + delivery_id: str | None = None + payload: dict[str, Any] + + +class BaseWebhookAdapter(ABC): + """Base class for provider-specific webhook adapters.""" + + webhook_type: WebhookType + signature_header: str + event_header: str + delivery_header: str + + def validate( + self, body: bytes, headers: Mapping[str, str], secret: str + ) -> WebhookEvent: + """Authenticate and structurally validate a provider event. + + Args: + body: The exact raw request body. + headers: The request headers. + secret: The integration signing secret. + + Returns: + The authenticated webhook event. + + Raises: + WebhookAuthenticationError: If the signature is invalid. + WebhookPayloadError: If the event metadata or payload is invalid. + """ # noqa: DOC502 + self.authenticate(body=body, headers=headers, secret=secret) + return self.parse(body=body, headers=headers) + + def parse(self, body: bytes, headers: Mapping[str, str]) -> WebhookEvent: + """Structurally validate and parse a provider event. + + Args: + body: The exact raw request body. + headers: The request headers. + + Returns: + The parsed webhook event. + + Raises: + WebhookPayloadError: If the event metadata or payload is invalid. + """ + event_type = headers.get(self.event_header) + if not event_type: + raise WebhookPayloadError( + f"Missing required {self.event_header} header." + ) + try: + payload = json.loads(body) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise WebhookPayloadError( + "Request body must be valid JSON." + ) from error + if not isinstance(payload, dict): + raise WebhookPayloadError( + "Request body must contain a top-level JSON object." + ) + return WebhookEvent( + webhook_type=self.webhook_type, + event_type=event_type, + delivery_id=headers.get(self.delivery_header), + payload=payload, + ) + + @abstractmethod + def authenticate( + self, body: bytes, headers: Mapping[str, str], secret: str + ) -> None: + """Authenticate the raw request body. + + Args: + body: The exact raw request body. + headers: The request headers. + secret: The integration signing secret. + """ + + +class HMACSHA256WebhookAdapter(BaseWebhookAdapter): + """Webhook adapter using a sha256-prefixed HMAC signature.""" + + def authenticate( + self, body: bytes, headers: Mapping[str, str], secret: str + ) -> None: + """Validate an HMAC-SHA256 signature over the raw request body. + + Args: + body: The exact raw request body. + headers: The request headers. + secret: The integration signing secret. + + Raises: + WebhookAuthenticationError: If the signature is missing, + malformed, or invalid. + """ + signature = headers.get(self.signature_header) + if not signature or not signature.startswith("sha256="): + raise WebhookAuthenticationError( + f"Missing or malformed {self.signature_header} header." + ) + expected = ( + "sha256=" + + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() + ) + if not hmac.compare_digest(signature, expected): + raise WebhookAuthenticationError("Invalid webhook signature.") + + +class GitHubWebhookAdapter(HMACSHA256WebhookAdapter): + """GitHub webhook adapter.""" + + webhook_type = WebhookType.GITHUB + signature_header = "x-hub-signature-256" + event_header = "x-github-event" + delivery_header = "x-github-delivery" + + +class CustomWebhookAdapter(HMACSHA256WebhookAdapter): + """ZenML custom webhook adapter.""" + + webhook_type = WebhookType.CUSTOM + signature_header = "x-zenml-signature-256" + event_header = "x-zenml-event" + delivery_header = "x-zenml-delivery" + + +_ADAPTERS: dict[WebhookType, BaseWebhookAdapter] = { + WebhookType.GITHUB: GitHubWebhookAdapter(), + WebhookType.CUSTOM: CustomWebhookAdapter(), +} + + +def get_webhook_adapter(webhook_type: WebhookType) -> BaseWebhookAdapter: + """Return the registered adapter for a webhook provider type. + + Args: + webhook_type: The webhook provider type. + + Returns: + The registered provider adapter. + """ + return _ADAPTERS[webhook_type] diff --git a/src/zenml/zen_server/rbac/models.py b/src/zenml/zen_server/rbac/models.py index 502e0dbd1e6..afd63843e55 100644 --- a/src/zenml/zen_server/rbac/models.py +++ b/src/zenml/zen_server/rbac/models.py @@ -78,6 +78,7 @@ class ResourceType(StrEnum): # Deactivated for now # USER = "user" TRIGGER = "trigger" + WEBHOOK_INTEGRATION = "webhook_integration" def is_project_scoped(self) -> bool: """Check if a resource type is project scoped. diff --git a/src/zenml/zen_server/rbac/utils.py b/src/zenml/zen_server/rbac/utils.py index 90eefbb0cba..02903baafa5 100644 --- a/src/zenml/zen_server/rbac/utils.py +++ b/src/zenml/zen_server/rbac/utils.py @@ -522,6 +522,8 @@ def get_resource_type_for_model( TagRequest, TagResponse, TriggerRequest, + WebhookIntegrationRequest, + WebhookIntegrationResponse, ) mapping: Dict[ @@ -581,6 +583,8 @@ def get_resource_type_for_model( ScheduleTriggerRequest: ResourceType.TRIGGER, ScheduleTriggerResponse: ResourceType.TRIGGER, TriggerRequest: ResourceType.TRIGGER, + WebhookIntegrationRequest: ResourceType.WEBHOOK_INTEGRATION, + WebhookIntegrationResponse: ResourceType.WEBHOOK_INTEGRATION, } return mapping.get(type(model)) diff --git a/src/zenml/zen_server/routers/webhook_integration_endpoints.py b/src/zenml/zen_server/routers/webhook_integration_endpoints.py new file mode 100644 index 00000000000..05f92db53f5 --- /dev/null +++ b/src/zenml/zen_server/routers/webhook_integration_endpoints.py @@ -0,0 +1,302 @@ +# Copyright (c) ZenML GmbH 2026. All Rights Reserved. +"""Management and public intake endpoints for webhook integrations.""" + +from uuid import UUID + +from fastapi import ( + APIRouter, + Depends, + HTTPException, + Request, + Security, + status, +) +from fastapi.responses import Response +from starlette.concurrency import run_in_threadpool +from starlette.datastructures import Headers + +from zenml.constants import ( + API, + VERSION_1, + WEBHOOK_INTEGRATIONS, + WEBHOOKS, +) +from zenml.enums import WebhookType +from zenml.models import ( + Page, + WebhookEventStatsUpdate, + WebhookIntegrationCreateResponse, + WebhookIntegrationFilter, + WebhookIntegrationRequest, + WebhookIntegrationResponse, + WebhookIntegrationSecretRequest, + WebhookIntegrationSecretResponse, + WebhookIntegrationUpdate, +) +from zenml.webhooks import ( + WebhookAuthenticationError, + WebhookPayloadError, + get_webhook_adapter, +) +from zenml.zen_server.auth import AuthContext, authorize +from zenml.zen_server.exceptions import error_response +from zenml.zen_server.rbac.endpoint_utils import ( + verify_permissions_and_delete_entity, + verify_permissions_and_get_entity, + verify_permissions_and_list_entities, + verify_permissions_and_update_entity, +) +from zenml.zen_server.rbac.models import Action, ResourceType +from zenml.zen_server.rbac.utils import verify_permission_for_model +from zenml.zen_server.utils import ( + async_fastapi_endpoint_wrapper, + async_handle_endpoint_errors, + make_dependable, + zen_store, +) + +management_router = APIRouter( + prefix=API + VERSION_1 + WEBHOOK_INTEGRATIONS, + tags=["webhook_integrations"], + responses={401: error_response, 403: error_response}, +) + +intake_router = APIRouter( + prefix=API + VERSION_1 + WEBHOOKS, + tags=["webhooks"], +) + + +@management_router.post("") +@async_fastapi_endpoint_wrapper +def create_webhook_integration( + integration: WebhookIntegrationRequest, + _: AuthContext = Security(authorize), +) -> WebhookIntegrationCreateResponse: + """Create a project-scoped webhook integration. + + Args: + integration: The webhook integration creation request. + + Returns: + The created integration and any generated signing secret. + """ + verify_permission_for_model(model=integration, action=Action.CREATE) + return zen_store().create_webhook_integration(integration) + + +@management_router.get("") +@async_fastapi_endpoint_wrapper +def list_webhook_integrations( + filter_model: WebhookIntegrationFilter = Depends( + make_dependable(WebhookIntegrationFilter) + ), + hydrate: bool = False, + _: AuthContext = Security(authorize), +) -> Page[WebhookIntegrationResponse]: + """List webhook integrations. + + Args: + filter_model: The webhook integration filters. + hydrate: Whether to include intake statistics. + + Returns: + A page of webhook integrations. + """ + return verify_permissions_and_list_entities( + filter_model=filter_model, + resource_type=ResourceType.WEBHOOK_INTEGRATION, + list_method=zen_store().list_webhook_integrations, + hydrate=hydrate, + ) + + +@management_router.get("/{integration_id}") +@async_fastapi_endpoint_wrapper +def get_webhook_integration( + integration_id: UUID, + hydrate: bool = True, + _: AuthContext = Security(authorize), +) -> WebhookIntegrationResponse: + """Get a webhook integration. + + Args: + integration_id: The webhook integration ID. + hydrate: Whether to include intake statistics. + + Returns: + The webhook integration. + """ + return verify_permissions_and_get_entity( + id=integration_id, + get_method=zen_store().get_webhook_integration, + hydrate=hydrate, + ) + + +@management_router.put("/{integration_id}") +@async_fastapi_endpoint_wrapper +def update_webhook_integration( + integration_id: UUID, + update: WebhookIntegrationUpdate, + _: AuthContext = Security(authorize), +) -> WebhookIntegrationResponse: + """Update a webhook integration. + + Args: + integration_id: The webhook integration ID. + update: The webhook integration update. + + Returns: + The updated webhook integration. + """ + return verify_permissions_and_update_entity( + id=integration_id, + update_model=update, + get_method=zen_store().get_webhook_integration, + update_method=zen_store().update_webhook_integration, + ) + + +@management_router.delete("/{integration_id}") +@async_fastapi_endpoint_wrapper +def delete_webhook_integration( + integration_id: UUID, + _: AuthContext = Security(authorize), +) -> None: + """Delete a webhook integration and its signing secret. + + Args: + integration_id: The webhook integration ID. + """ + verify_permissions_and_delete_entity( + id=integration_id, + get_method=zen_store().get_webhook_integration, + delete_method=zen_store().delete_webhook_integration, + ) + + +@management_router.put("/{integration_id}/secret") +@async_fastapi_endpoint_wrapper +def rotate_webhook_integration_secret( + integration_id: UUID, + request: WebhookIntegrationSecretRequest, + _: AuthContext = Security(authorize), +) -> WebhookIntegrationSecretResponse: + """Rotate a webhook integration signing secret. + + Args: + integration_id: The webhook integration ID. + request: The secret rotation request. + + Returns: + The newly active signing secret. + """ + integration = zen_store().get_webhook_integration(integration_id) + verify_permission_for_model(model=integration, action=Action.UPDATE) + return zen_store().rotate_webhook_integration_secret( + integration_id=integration_id, request=request + ) + + +@intake_router.post( + "/{webhook_type}/{integration_id}/events", + status_code=status.HTTP_202_ACCEPTED, +) +@async_handle_endpoint_errors +async def receive_webhook_event( + webhook_type: WebhookType, + integration_id: UUID, + request: Request, +) -> Response: + """Authenticate and accept a provider webhook event. + + Args: + webhook_type: The provider type from the public endpoint path. + integration_id: The webhook integration ID. + request: The raw HTTP request. + + Returns: + An empty accepted response. + """ + body = await request.body() + return await run_in_threadpool( + _receive_webhook_event, + webhook_type=webhook_type, + integration_id=integration_id, + body=body, + headers=request.headers, + ) + + +def _receive_webhook_event( + webhook_type: WebhookType, + integration_id: UUID, + body: bytes, + headers: Headers, +) -> Response: + """Synchronously authenticate and accept a webhook event. + + Args: + webhook_type: The provider type from the public endpoint path. + integration_id: The webhook integration ID. + body: The raw request body. + headers: The request headers. + + Returns: + An empty accepted response. + + Raises: + HTTPException: If the integration cannot accept the event or the + request fails authentication or payload validation. + """ + try: + integration = zen_store().get_webhook_integration(integration_id) + except KeyError as error: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) from error + + if integration.webhook_type != webhook_type: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + + secret = zen_store().get_webhook_integration_secret(integration_id) + adapter = get_webhook_adapter(webhook_type) + + try: + adapter.authenticate(body=body, headers=headers, secret=secret) + except WebhookAuthenticationError as error: + if integration.active: + zen_store().record_webhook_event( + integration_id, + WebhookEventStatsUpdate( + auth_failed=True, error_summary=str(error) + ), + ) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid webhook signature.", + ) from error + + if not integration.active: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Webhook integration is inactive.", + ) + + try: + adapter.parse(body=body, headers=headers) + except WebhookPayloadError as error: + zen_store().record_webhook_event( + integration_id, + WebhookEventStatsUpdate( + invalid_payload=True, error_summary=str(error) + ), + ) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(error), + ) from error + + zen_store().record_webhook_event( + integration_id, WebhookEventStatsUpdate(accepted=True) + ) + return Response(status_code=status.HTTP_202_ACCEPTED) diff --git a/src/zenml/zen_server/zen_server_api.py b/src/zenml/zen_server/zen_server_api.py index c908f862ce7..fca6dd838ce 100644 --- a/src/zenml/zen_server/zen_server_api.py +++ b/src/zenml/zen_server/zen_server_api.py @@ -95,6 +95,7 @@ tags_endpoints, trigger_endpoints, users_endpoints, + webhook_integration_endpoints, ) from zenml.zen_server.secure_headers import ( initialize_secure_headers, @@ -349,6 +350,8 @@ async def dashboard(request: Request) -> Any: app.include_router(resource_pool_subject_policies_endpoints.router) app.include_router(resource_requests_endpoints.router) app.include_router(trigger_endpoints.router) +app.include_router(webhook_integration_endpoints.management_router) +app.include_router(webhook_integration_endpoints.intake_router) # When the auth scheme is set to EXTERNAL, users cannot be managed via the # API. diff --git a/src/zenml/zen_stores/migrations/versions/7c0d9e4a1b2f_add_webhook_integrations.py b/src/zenml/zen_stores/migrations/versions/7c0d9e4a1b2f_add_webhook_integrations.py new file mode 100644 index 00000000000..dc24e8a7d15 --- /dev/null +++ b/src/zenml/zen_stores/migrations/versions/7c0d9e4a1b2f_add_webhook_integrations.py @@ -0,0 +1,95 @@ +"""Add webhook integrations [7c0d9e4a1b2f]. + +Revision ID: 7c0d9e4a1b2f +Revises: b6f2a8d9c3e1 +Create Date: 2026-07-07 + +""" + +import sqlalchemy as sa +import sqlmodel +from alembic import op + +revision = "7c0d9e4a1b2f" +down_revision = "b6f2a8d9c3e1" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Create the webhook integration table.""" + op.create_table( + "webhook_integration", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("created", sa.DateTime(), nullable=False), + sa.Column("updated", sa.DateTime(), nullable=False), + sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("project_id", sa.Uuid(), nullable=False), + sa.Column("user_id", sa.Uuid(), nullable=True), + sa.Column("secret_id", sa.Uuid(), nullable=False), + sa.Column( + "webhook_type", sqlmodel.sql.sqltypes.AutoString(), nullable=False + ), + sa.Column("active", sa.Boolean(), nullable=False), + sa.Column("received_count", sa.Integer(), nullable=False), + sa.Column("accepted_count", sa.Integer(), nullable=False), + sa.Column("auth_failed_count", sa.Integer(), nullable=False), + sa.Column("invalid_payload_count", sa.Integer(), nullable=False), + sa.Column("last_received_at", sa.DateTime(), nullable=True), + sa.Column("last_accepted_at", sa.DateTime(), nullable=True), + sa.Column("last_error_at", sa.DateTime(), nullable=True), + sa.Column( + "last_error_summary", + sa.TEXT(), + nullable=True, + ), + sa.ForeignKeyConstraint( + ["project_id"], + ["project.id"], + name="fk_webhook_integration_project_id_project", + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["user.id"], + name="fk_webhook_integration_user_id_user", + ondelete="SET NULL", + ), + sa.ForeignKeyConstraint( + ["secret_id"], + ["secret.id"], + name="fk_webhook_integration_secret_id_secret", + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "name", + "project_id", + name="unique_webhook_integration_name_in_project", + ), + ) + op.create_index( + op.f("ix_webhook_integration_active"), + "webhook_integration", + ["active"], + unique=False, + ) + op.create_index( + op.f("ix_webhook_integration_webhook_type"), + "webhook_integration", + ["webhook_type"], + unique=False, + ) + + +def downgrade() -> None: + """Drop the webhook integration table.""" + op.drop_index( + op.f("ix_webhook_integration_webhook_type"), + table_name="webhook_integration", + ) + op.drop_index( + op.f("ix_webhook_integration_active"), + table_name="webhook_integration", + ) + op.drop_table("webhook_integration") diff --git a/src/zenml/zen_stores/rest_zen_store.py b/src/zenml/zen_stores/rest_zen_store.py index 80365b8d8b4..70d97f7a64d 100644 --- a/src/zenml/zen_stores/rest_zen_store.py +++ b/src/zenml/zen_stores/rest_zen_store.py @@ -126,6 +126,7 @@ TRIGGERS, USERS, VERSION_1, + WEBHOOK_INTEGRATIONS, ZENML_PRO_API_KEY_PREFIX, ) from zenml.enums import ( @@ -307,6 +308,13 @@ UserRequest, UserResponse, UserUpdate, + WebhookIntegrationCreateResponse, + WebhookIntegrationFilter, + WebhookIntegrationRequest, + WebhookIntegrationResponse, + WebhookIntegrationSecretRequest, + WebhookIntegrationSecretResponse, + WebhookIntegrationUpdate, ) from zenml.service_connectors.service_connector_registry import ( service_connector_registry, @@ -2577,6 +2585,109 @@ def create_run_metadata(self, run_metadata: RunMetadataRequest) -> None: """ self.post(RUN_METADATA, body=run_metadata) + # ------------------------ Webhook integrations ----------------------- + + def create_webhook_integration( + self, integration: WebhookIntegrationRequest + ) -> WebhookIntegrationCreateResponse: + """Create a webhook integration. + + Args: + integration: The webhook integration creation request. + + Returns: + The created integration and any generated signing secret. + """ + response = self.post(WEBHOOK_INTEGRATIONS, body=integration) + return WebhookIntegrationCreateResponse.model_validate(response) + + def get_webhook_integration( + self, integration_id: UUID, hydrate: bool = True + ) -> WebhookIntegrationResponse: + """Get a webhook integration. + + Args: + integration_id: The webhook integration ID. + hydrate: Whether to include intake statistics. + + Returns: + The webhook integration. + """ + response = self.get( + f"{WEBHOOK_INTEGRATIONS}/{integration_id}", + params={"hydrate": hydrate}, + ) + return WebhookIntegrationResponse.model_validate(response) + + def list_webhook_integrations( + self, + filter_model: WebhookIntegrationFilter, + hydrate: bool = False, + ) -> Page[WebhookIntegrationResponse]: + """List webhook integrations. + + Args: + filter_model: The webhook integration filters. + hydrate: Whether to include intake statistics. + + Returns: + A page of webhook integrations. + """ + response = self.get( + WEBHOOK_INTEGRATIONS, + params={ + "hydrate": hydrate, + **filter_model.model_dump(exclude_none=True), + }, + ) + return Page[WebhookIntegrationResponse].model_validate(response) + + def update_webhook_integration( + self, + integration_id: UUID, + update: WebhookIntegrationUpdate, + ) -> WebhookIntegrationResponse: + """Update a webhook integration. + + Args: + integration_id: The webhook integration ID. + update: The webhook integration update. + + Returns: + The updated webhook integration. + """ + response = self.put( + f"{WEBHOOK_INTEGRATIONS}/{integration_id}", body=update + ) + return WebhookIntegrationResponse.model_validate(response) + + def delete_webhook_integration(self, integration_id: UUID) -> None: + """Delete a webhook integration. + + Args: + integration_id: The webhook integration ID. + """ + self.delete(f"{WEBHOOK_INTEGRATIONS}/{integration_id}") + + def rotate_webhook_integration_secret( + self, + integration_id: UUID, + request: WebhookIntegrationSecretRequest, + ) -> WebhookIntegrationSecretResponse: + """Rotate a webhook integration signing secret. + + Args: + integration_id: The webhook integration ID. + request: The secret rotation request. + + Returns: + The newly active signing secret. + """ + response = self.put( + f"{WEBHOOK_INTEGRATIONS}/{integration_id}/secret", body=request + ) + return WebhookIntegrationSecretResponse.model_validate(response) + # ----------------------------- Triggers ------------------------------ def create_trigger( diff --git a/src/zenml/zen_stores/schemas/__init__.py b/src/zenml/zen_stores/schemas/__init__.py index c6a8f52e309..f506995aa91 100644 --- a/src/zenml/zen_stores/schemas/__init__.py +++ b/src/zenml/zen_stores/schemas/__init__.py @@ -110,6 +110,9 @@ ) from zenml.zen_stores.schemas.trigger_schemas import TriggerSchema from zenml.zen_stores.schemas.user_schemas import UserSchema +from zenml.zen_stores.schemas.webhook_integration_schemas import ( + WebhookIntegrationSchema, +) __all__ = [ "APIKeySchema", @@ -172,4 +175,5 @@ "TriggerSchema", "TriggerSnapshotSchema", "TriggerExecutionSchema", + "WebhookIntegrationSchema", ] diff --git a/src/zenml/zen_stores/schemas/project_schemas.py b/src/zenml/zen_stores/schemas/project_schemas.py index 144f90ca15c..97603939a10 100644 --- a/src/zenml/zen_stores/schemas/project_schemas.py +++ b/src/zenml/zen_stores/schemas/project_schemas.py @@ -46,6 +46,7 @@ ServiceSchema, StepRunSchema, TriggerSchema, + WebhookIntegrationSchema, ) @@ -132,6 +133,10 @@ class ProjectSchema(NamedSchema, table=True): back_populates="project", sa_relationship_kwargs={"cascade": "delete"}, ) + webhook_integrations: list["WebhookIntegrationSchema"] = Relationship( + back_populates="project", + sa_relationship_kwargs={"cascade": "delete"}, + ) @classmethod def from_request(cls, project: ProjectRequest) -> "ProjectSchema": diff --git a/src/zenml/zen_stores/schemas/webhook_integration_schemas.py b/src/zenml/zen_stores/schemas/webhook_integration_schemas.py new file mode 100644 index 00000000000..c3de84c57f7 --- /dev/null +++ b/src/zenml/zen_stores/schemas/webhook_integration_schemas.py @@ -0,0 +1,171 @@ +# Copyright (c) ZenML GmbH 2026. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +"""SQL schema for webhook integrations.""" + +from datetime import datetime +from typing import Any +from uuid import UUID + +from sqlalchemy import TEXT, Column, UniqueConstraint +from sqlmodel import Field, Relationship + +from zenml.constants import API, VERSION_1, WEBHOOKS +from zenml.models import ( + WebhookIntegrationRequest, + WebhookIntegrationResponse, + WebhookIntegrationResponseBody, + WebhookIntegrationResponseMetadata, + WebhookIntegrationStats, + WebhookIntegrationUpdate, +) +from zenml.utils.time_utils import utc_now +from zenml.zen_stores.schemas.base_schemas import NamedSchema +from zenml.zen_stores.schemas.project_schemas import ProjectSchema +from zenml.zen_stores.schemas.schema_utils import build_foreign_key_field +from zenml.zen_stores.schemas.secret_schemas import SecretSchema +from zenml.zen_stores.schemas.user_schemas import UserSchema + + +class WebhookIntegrationSchema(NamedSchema, table=True): + """SQL schema for a project-scoped webhook integration.""" + + __tablename__ = "webhook_integration" + __table_args__ = ( + UniqueConstraint( + "name", + "project_id", + name="unique_webhook_integration_name_in_project", + ), + ) + + project_id: UUID = build_foreign_key_field( + source=__tablename__, + target=ProjectSchema.__tablename__, + source_column="project_id", + target_column="id", + ondelete="CASCADE", + nullable=False, + ) + project: "ProjectSchema" = Relationship( + back_populates="webhook_integrations" + ) + user_id: UUID | None = build_foreign_key_field( + source=__tablename__, + target=UserSchema.__tablename__, + source_column="user_id", + target_column="id", + ondelete="SET NULL", + nullable=True, + ) + user: UserSchema | None = Relationship() + secret_id: UUID = build_foreign_key_field( + source=__tablename__, + target=SecretSchema.__tablename__, + source_column="secret_id", + target_column="id", + ondelete="CASCADE", + nullable=False, + ) + webhook_type: str = Field(index=True) + active: bool = Field(default=True, index=True) + received_count: int = 0 + accepted_count: int = 0 + auth_failed_count: int = 0 + invalid_payload_count: int = 0 + last_received_at: datetime | None = None + last_accepted_at: datetime | None = None + last_error_at: datetime | None = None + last_error_summary: str | None = Field( + default=None, sa_column=Column(TEXT, nullable=True) + ) + + @classmethod + def from_request( + cls, request: WebhookIntegrationRequest, secret_id: UUID + ) -> "WebhookIntegrationSchema": + """Create a schema from a request. + + Args: + request: The webhook integration creation request. + secret_id: The internal signing secret ID. + + Returns: + The created webhook integration schema. + """ + return cls( + name=request.name, + project_id=request.project, + user_id=request.user, + webhook_type=request.webhook_type.value, + active=request.active, + secret_id=secret_id, + ) + + def to_model( + self, + include_metadata: bool = False, + include_resources: bool = False, + **kwargs: Any, + ) -> WebhookIntegrationResponse: + """Convert the schema to a response model. + + Args: + include_metadata: Whether to include intake statistics. + include_resources: Whether to include associated resources. + **kwargs: Additional conversion options. + + Returns: + The webhook integration response. + """ + metadata = None + if include_metadata: + metadata = WebhookIntegrationResponseMetadata( + stats=WebhookIntegrationStats( + received_count=self.received_count, + accepted_count=self.accepted_count, + auth_failed_count=self.auth_failed_count, + invalid_payload_count=self.invalid_payload_count, + last_received_at=self.last_received_at, + last_accepted_at=self.last_accepted_at, + last_error_at=self.last_error_at, + last_error_summary=self.last_error_summary, + ) + ) + return WebhookIntegrationResponse( + id=self.id, + name=self.name, + body=WebhookIntegrationResponseBody( + user_id=self.user_id, + project_id=self.project_id, + created=self.created, + updated=self.updated, + webhook_type=self.webhook_type, + active=self.active, + endpoint_path=( + f"{API}{VERSION_1}{WEBHOOKS}/{self.webhook_type}/" + f"{self.id}/events" + ), + ), + metadata=metadata, + ) + + def update( + self, update: WebhookIntegrationUpdate + ) -> "WebhookIntegrationSchema": + """Apply a webhook integration update. + + Args: + update: The webhook integration update. + + Returns: + The updated webhook integration schema. + """ + if update.name is not None: + self.name = update.name + if update.active is not None: + self.active = update.active + self.updated = utc_now() + return self diff --git a/src/zenml/zen_stores/sql_zen_store.py b/src/zenml/zen_stores/sql_zen_store.py index d7a4d49ac70..f26c89e0cb1 100644 --- a/src/zenml/zen_stores/sql_zen_store.py +++ b/src/zenml/zen_stores/sql_zen_store.py @@ -43,6 +43,7 @@ import os import random import re +import secrets import sys import time import uuid @@ -374,6 +375,14 @@ UserResponse, UserScopedRequest, UserUpdate, + WebhookEventStatsUpdate, + WebhookIntegrationCreateResponse, + WebhookIntegrationFilter, + WebhookIntegrationRequest, + WebhookIntegrationResponse, + WebhookIntegrationSecretRequest, + WebhookIntegrationSecretResponse, + WebhookIntegrationUpdate, ) from zenml.service_connectors.service_connector_registry import ( service_connector_registry, @@ -457,6 +466,7 @@ TriggerSchema, TriggerSnapshotSchema, UserSchema, + WebhookIntegrationSchema, ) from zenml.zen_stores.schemas.artifact_visualization_schemas import ( ArtifactVisualizationSchema, @@ -8111,6 +8121,270 @@ def create_run_metadata(self, run_metadata: RunMetadataRequest) -> None: session.commit() return None + # -------------------- Webhook integrations --------------------- + + def _create_webhook_integration_secret( + self, secret: str, session: Session + ) -> UUID: + """Create an internal secret containing a webhook signing secret. + + Args: + secret: The signing secret value. + session: The active database session. + + Returns: + The internal secret ID. + """ + return self._create_secret_schema( + SecretRequest( + user=self._get_active_user(session=session).id, + name=f"webhook-{uuid.uuid4().hex}", + private=False, + values={"secret": PlainSerializedSecretStr(secret)}, + ), + session=session, + internal=True, + ).id + + def create_webhook_integration( + self, integration: WebhookIntegrationRequest + ) -> WebhookIntegrationCreateResponse: + """Create a webhook integration and its internal signing secret. + + Args: + integration: The webhook integration creation request. + + Returns: + The created integration and any generated signing secret. + """ # noqa: DOC501, DOC503 + generated_secret = integration.secret is None + if integration.secret is None: + secret = secrets.token_urlsafe(32) + else: + secret = integration.secret.get_secret_value() + with Session(self.engine) as session: + self._set_request_user_id( + request_model=integration, session=session + ) + self._verify_name_uniqueness( + resource=integration, + schema=WebhookIntegrationSchema, + session=session, + ) + secret_id = self._create_webhook_integration_secret( + secret=secret, + session=session, + ) + try: + schema = WebhookIntegrationSchema.from_request( + request=integration, secret_id=secret_id + ) + session.add(schema) + session.commit() + except Exception: + session.rollback() + self._delete_secret_schema( + secret_id=secret_id, session=session + ) + raise + return WebhookIntegrationCreateResponse( + integration=schema.to_model(include_metadata=True), + secret=( + PlainSerializedSecretStr(secret) + if generated_secret + else None + ), + ) + + def get_webhook_integration( + self, integration_id: UUID, hydrate: bool = True + ) -> WebhookIntegrationResponse: + """Get a webhook integration. + + Args: + integration_id: The webhook integration ID. + hydrate: Whether to include intake statistics. + + Returns: + The webhook integration. + """ + with Session(self.engine) as session: + schema = self._get_schema_by_id( + resource_id=integration_id, + schema_class=WebhookIntegrationSchema, + session=session, + ) + return schema.to_model(include_metadata=hydrate) + + def list_webhook_integrations( + self, + filter_model: WebhookIntegrationFilter, + hydrate: bool = False, + ) -> Page[WebhookIntegrationResponse]: + """List webhook integrations in the active project. + + Args: + filter_model: The webhook integration filters. + hydrate: Whether to include intake statistics. + + Returns: + A page of webhook integrations. + """ + with Session(self.engine) as session: + self._set_filter_project_id( + filter_model=filter_model, session=session + ) + return self.filter_and_paginate( + session=session, + query=select(WebhookIntegrationSchema), + table=WebhookIntegrationSchema, + filter_model=filter_model, + hydrate=hydrate, + ) + + def update_webhook_integration( + self, + integration_id: UUID, + update: WebhookIntegrationUpdate, + ) -> WebhookIntegrationResponse: + """Update a webhook integration. + + Args: + integration_id: The webhook integration ID. + update: The webhook integration update. + + Returns: + The updated webhook integration. + """ + with Session(self.engine) as session: + schema = self._get_schema_by_id( + resource_id=integration_id, + schema_class=WebhookIntegrationSchema, + session=session, + ) + self._verify_name_uniqueness( + resource=update, schema=schema, session=session + ) + schema.update(update) + session.add(schema) + session.commit() + return schema.to_model(include_metadata=True) + + def delete_webhook_integration(self, integration_id: UUID) -> None: + """Delete a webhook integration and its internal secret. + + Args: + integration_id: The webhook integration ID. + """ + with Session(self.engine) as session: + schema = self._get_schema_by_id( + resource_id=integration_id, + schema_class=WebhookIntegrationSchema, + session=session, + ) + self._delete_secret_schema( + secret_id=schema.secret_id, session=session + ) + + def rotate_webhook_integration_secret( + self, + integration_id: UUID, + request: WebhookIntegrationSecretRequest, + ) -> WebhookIntegrationSecretResponse: + """Replace the active webhook signing secret. + + Args: + integration_id: The webhook integration ID. + request: The secret rotation request. + + Returns: + The newly active signing secret. + """ + secret = ( + request.secret.get_secret_value() + if request.secret is not None + else secrets.token_urlsafe(32) + ) + with Session(self.engine) as session: + schema = self._get_schema_by_id( + resource_id=integration_id, + schema_class=WebhookIntegrationSchema, + session=session, + ) + self._update_secret_values( + secret_id=schema.secret_id, + values={"secret": secret}, + overwrite=True, + ) + schema.updated = utc_now() + session.add(schema) + session.commit() + return WebhookIntegrationSecretResponse( + secret=PlainSerializedSecretStr(secret) + ) + + def get_webhook_integration_secret(self, integration_id: UUID) -> str: + """Get the signing secret used to validate webhook requests. + + Args: + integration_id: The webhook integration ID. + + Returns: + The signing secret. + """ + with Session(self.engine) as session: + schema = self._get_schema_by_id( + resource_id=integration_id, + schema_class=WebhookIntegrationSchema, + session=session, + ) + return self._get_secret_values(schema.secret_id)["secret"] + + def record_webhook_event( + self, integration_id: UUID, update: WebhookEventStatsUpdate + ) -> None: + """Atomically record a webhook intake outcome. + + Args: + integration_id: The webhook integration ID. + update: The terminal intake outcome. + + Raises: + KeyError: If the webhook integration no longer exists. + """ + now = utc_now() + values: dict[str, Any] = { + "received_count": WebhookIntegrationSchema.received_count + 1, + "last_received_at": now, + } + if update.accepted: + values["accepted_count"] = ( + WebhookIntegrationSchema.accepted_count + 1 + ) + values["last_accepted_at"] = now + elif update.auth_failed: + values["auth_failed_count"] = ( + WebhookIntegrationSchema.auth_failed_count + 1 + ) + elif update.invalid_payload: + values["invalid_payload_count"] = ( + WebhookIntegrationSchema.invalid_payload_count + 1 + ) + if update.error_summary is not None: + values["last_error_at"] = now + values["last_error_summary"] = update.error_summary + with Session(self.engine) as session: + result = session.exec( + sqlalchemy.update(WebhookIntegrationSchema) + .where(col(WebhookIntegrationSchema.id) == integration_id) + .values(**values) + ) + if result.rowcount == 0: + raise KeyError( + f"Webhook integration {integration_id} not found." + ) + session.commit() + # -------------------- Triggers --------------------- @track_decorator(AnalyticsEvent.CREATED_TRIGGER) @@ -13476,8 +13750,17 @@ def delete_project(self, project_name_or_id: Union[str, UUID]) -> None: "The default project cannot be deleted." ) + webhook_secret_ids = session.exec( + select(WebhookIntegrationSchema.secret_id).where( + WebhookIntegrationSchema.project_id == project.id + ) + ).all() session.delete(project) session.commit() + for secret_id in webhook_secret_ids: + self._delete_secret_schema( + secret_id=secret_id, session=session + ) def count_projects( self, filter_model: Optional[ProjectFilter] = None diff --git a/src/zenml/zen_stores/zen_store_interface.py b/src/zenml/zen_stores/zen_store_interface.py index f15f7467ba5..7e347dc44d4 100644 --- a/src/zenml/zen_stores/zen_store_interface.py +++ b/src/zenml/zen_stores/zen_store_interface.py @@ -167,6 +167,13 @@ UserRequest, UserResponse, UserUpdate, + WebhookIntegrationCreateResponse, + WebhookIntegrationFilter, + WebhookIntegrationRequest, + WebhookIntegrationResponse, + WebhookIntegrationSecretRequest, + WebhookIntegrationSecretResponse, + WebhookIntegrationUpdate, ) from zenml.zen_stores.resource_pools.store_interface import ( ResourcePoolsStoreInterface, @@ -1824,6 +1831,91 @@ def create_run_metadata(self, run_metadata: RunMetadataRequest) -> None: None """ + # -------------------- Webhook integrations --------------------- + + @abstractmethod + def create_webhook_integration( + self, integration: WebhookIntegrationRequest + ) -> WebhookIntegrationCreateResponse: + """Create a webhook integration. + + Args: + integration: The webhook integration creation request. + + Returns: + The created integration and any generated signing secret. + """ + + @abstractmethod + def get_webhook_integration( + self, integration_id: UUID, hydrate: bool = True + ) -> WebhookIntegrationResponse: + """Get a webhook integration. + + Args: + integration_id: The webhook integration ID. + hydrate: Whether to include intake statistics. + + Returns: + The webhook integration. + """ + + @abstractmethod + def list_webhook_integrations( + self, + filter_model: WebhookIntegrationFilter, + hydrate: bool = False, + ) -> Page[WebhookIntegrationResponse]: + """List webhook integrations. + + Args: + filter_model: The webhook integration filters. + hydrate: Whether to include intake statistics. + + Returns: + A page of webhook integrations. + """ + + @abstractmethod + def update_webhook_integration( + self, + integration_id: UUID, + update: WebhookIntegrationUpdate, + ) -> WebhookIntegrationResponse: + """Update a webhook integration. + + Args: + integration_id: The webhook integration ID. + update: The webhook integration update. + + Returns: + The updated webhook integration. + """ + + @abstractmethod + def delete_webhook_integration(self, integration_id: UUID) -> None: + """Delete a webhook integration and its signing secret. + + Args: + integration_id: The webhook integration ID. + """ + + @abstractmethod + def rotate_webhook_integration_secret( + self, + integration_id: UUID, + request: WebhookIntegrationSecretRequest, + ) -> WebhookIntegrationSecretResponse: + """Rotate a webhook integration signing secret. + + Args: + integration_id: The webhook integration ID. + request: The secret rotation request. + + Returns: + The newly active signing secret. + """ + # -------------------- Triggers --------------------- @abstractmethod From 11fab934c19b4ec8ec4edd749ceb6dbb6253411c Mon Sep 17 00:00:00 2001 From: Iason Andriopoulos Date: Wed, 8 Jul 2026 18:22:30 +0300 Subject: [PATCH 02/18] Bug fixes --- src/zenml/models/v2/core/webhook_integration.py | 9 +++++++++ .../zen_stores/schemas/webhook_integration_schemas.py | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/src/zenml/models/v2/core/webhook_integration.py b/src/zenml/models/v2/core/webhook_integration.py index 1bd8e8a21ef..6db549aee21 100644 --- a/src/zenml/models/v2/core/webhook_integration.py +++ b/src/zenml/models/v2/core/webhook_integration.py @@ -133,6 +133,15 @@ def endpoint_path(self) -> str: """ return self.get_body().endpoint_path + @property + def stats(self) -> WebhookIntegrationStats: + """Return intake statistics for this webhook integration. + + Returns: + Intake statistics for this webhook integration. + """ + return self.get_metadata().stats + class WebhookIntegrationFilter(ProjectScopedFilter): """Filter model for webhook integrations.""" diff --git a/src/zenml/zen_stores/schemas/webhook_integration_schemas.py b/src/zenml/zen_stores/schemas/webhook_integration_schemas.py index c3de84c57f7..c7646cdd2b0 100644 --- a/src/zenml/zen_stores/schemas/webhook_integration_schemas.py +++ b/src/zenml/zen_stores/schemas/webhook_integration_schemas.py @@ -18,6 +18,7 @@ WebhookIntegrationResponse, WebhookIntegrationResponseBody, WebhookIntegrationResponseMetadata, + WebhookIntegrationResponseResources, WebhookIntegrationStats, WebhookIntegrationUpdate, ) @@ -134,6 +135,13 @@ def to_model( last_error_summary=self.last_error_summary, ) ) + + resources = None + if include_resources: + resources = WebhookIntegrationResponseResources( + user=self.user.to_model() if self.user else None, + ) + return WebhookIntegrationResponse( id=self.id, name=self.name, @@ -150,6 +158,7 @@ def to_model( ), ), metadata=metadata, + resources=resources, ) def update( From 30ca73cf4b5f5b6ead66b6a5e274a7200bc63135 Mon Sep 17 00:00:00 2001 From: Iason Andriopoulos Date: Thu, 9 Jul 2026 14:08:28 +0300 Subject: [PATCH 03/18] Add unit tests --- tests/unit/webhooks/test_adapters.py | 123 ++++++++++ .../test_webhook_integration_endpoints.py | 222 ++++++++++++++++++ .../test_webhook_integration_schemas.py | 101 ++++++++ 3 files changed, 446 insertions(+) create mode 100644 tests/unit/webhooks/test_adapters.py create mode 100644 tests/unit/zen_server/endpoints/test_webhook_integration_endpoints.py create mode 100644 tests/unit/zen_stores/schemas/test_webhook_integration_schemas.py diff --git a/tests/unit/webhooks/test_adapters.py b/tests/unit/webhooks/test_adapters.py new file mode 100644 index 00000000000..a2598a3abc7 --- /dev/null +++ b/tests/unit/webhooks/test_adapters.py @@ -0,0 +1,123 @@ +import hashlib +import hmac +from collections.abc import Mapping + +import pytest + +from zenml.enums import WebhookType +from zenml.webhooks.adapters import ( + CustomWebhookAdapter, + GitHubWebhookAdapter, + WebhookAuthenticationError, + WebhookPayloadError, + get_webhook_adapter, +) + + +def _signature(secret: str, body: bytes) -> str: + return ( + "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() + ) + + +@pytest.mark.parametrize( + "webhook_type, adapter_type", + [ + (WebhookType.GITHUB, GitHubWebhookAdapter), + (WebhookType.CUSTOM, CustomWebhookAdapter), + ], +) +def test_get_webhook_adapter_returns_registered_adapter( + webhook_type: WebhookType, adapter_type: type +) -> None: + adapter = get_webhook_adapter(webhook_type) + + assert isinstance(adapter, adapter_type) + assert adapter.webhook_type == webhook_type + + +@pytest.mark.parametrize( + "adapter, headers", + [ + ( + GitHubWebhookAdapter(), + { + "x-github-event": "push", + "x-github-delivery": "github-delivery-id", + }, + ), + ( + CustomWebhookAdapter(), + { + "x-zenml-event": "pipeline.ready", + "x-zenml-delivery": "custom-delivery-id", + }, + ), + ], +) +def test_validate_returns_provider_event_for_signed_request( + adapter: GitHubWebhookAdapter | CustomWebhookAdapter, + headers: dict[str, str], +) -> None: + secret = "webhook-secret" + body = b'{"repository":"zenml","run_id":42}' + headers[adapter.signature_header] = _signature(secret, body) + + event = adapter.validate(body=body, headers=headers, secret=secret) + + assert event.webhook_type == adapter.webhook_type + assert event.event_type == headers[adapter.event_header] + assert event.delivery_id == headers[adapter.delivery_header] + assert event.payload == {"repository": "zenml", "run_id": 42} + + +def test_authentication_uses_exact_raw_body_bytes() -> None: + adapter = CustomWebhookAdapter() + secret = "webhook-secret" + signed_body = b'{"repository":"zenml","run_id":42}' + equivalent_body = b'{\n "repository": "zenml",\n "run_id": 42\n}' + headers = { + "x-zenml-event": "pipeline.ready", + "x-zenml-signature-256": _signature(secret, signed_body), + } + + with pytest.raises(WebhookAuthenticationError): + adapter.validate(body=equivalent_body, headers=headers, secret=secret) + + +@pytest.mark.parametrize( + "headers", + [ + {}, + {"x-zenml-signature-256": "not-prefixed"}, + {"x-zenml-signature-256": "sha256=invalid"}, + ], +) +def test_authentication_rejects_missing_malformed_or_invalid_signature( + headers: Mapping[str, str], +) -> None: + adapter = CustomWebhookAdapter() + + with pytest.raises(WebhookAuthenticationError): + adapter.authenticate( + body=b'{"repository":"zenml"}', + headers=headers, + secret="webhook-secret", + ) + + +@pytest.mark.parametrize( + "body, headers", + [ + (b'{"repository":"zenml"}', {}), + (b"not-json", {"x-zenml-event": "pipeline.ready"}), + (b'["not", "an", "object"]', {"x-zenml-event": "pipeline.ready"}), + ], +) +def test_parse_rejects_missing_event_header_or_invalid_payload( + body: bytes, headers: Mapping[str, str] +) -> None: + adapter = CustomWebhookAdapter() + + with pytest.raises(WebhookPayloadError): + adapter.parse(body=body, headers=headers) diff --git a/tests/unit/zen_server/endpoints/test_webhook_integration_endpoints.py b/tests/unit/zen_server/endpoints/test_webhook_integration_endpoints.py new file mode 100644 index 00000000000..59bd99804e4 --- /dev/null +++ b/tests/unit/zen_server/endpoints/test_webhook_integration_endpoints.py @@ -0,0 +1,222 @@ +from types import SimpleNamespace +from uuid import uuid4 + +import pytest +from fastapi import HTTPException, status + +from zenml.enums import WebhookType +from zenml.webhooks import WebhookAuthenticationError, WebhookPayloadError +from zenml.zen_server.routers import webhook_integration_endpoints as endpoints + + +class _Store: + def __init__(self, integration: SimpleNamespace | None) -> None: + self.integration = integration + self.secret_requests = 0 + self.records = [] + + def get_webhook_integration(self, integration_id): + if self.integration is None: + raise KeyError(integration_id) + return self.integration + + def get_webhook_integration_secret(self, integration_id): + self.secret_requests += 1 + return "webhook-secret" + + def record_webhook_event(self, integration_id, update): + self.records.append((integration_id, update)) + + +class _Adapter: + def __init__( + self, + auth_error: Exception | None = None, + payload_error: Exception | None = None, + ) -> None: + self.auth_error = auth_error + self.payload_error = payload_error + self.authenticate_calls = 0 + self.parse_calls = 0 + + def authenticate(self, body, headers, secret): + self.authenticate_calls += 1 + if self.auth_error: + raise self.auth_error + + def parse(self, body, headers): + self.parse_calls += 1 + if self.payload_error: + raise self.payload_error + + +def _install_dependencies(monkeypatch, store: _Store, adapter: _Adapter): + monkeypatch.setattr(endpoints, "zen_store", lambda: store) + monkeypatch.setattr(endpoints, "get_webhook_adapter", lambda _: adapter) + + +def _receive(integration_id): + return endpoints._receive_webhook_event( + webhook_type=WebhookType.CUSTOM, + integration_id=integration_id, + body=b'{"event":"ready"}', + headers={}, + ) + + +def test_receive_webhook_event_returns_404_for_missing_integration( + monkeypatch, +) -> None: + integration_id = uuid4() + store = _Store(integration=None) + adapter = _Adapter() + _install_dependencies(monkeypatch, store, adapter) + + with pytest.raises(HTTPException) as error: + _receive(integration_id) + + assert error.value.status_code == status.HTTP_404_NOT_FOUND + assert store.secret_requests == 0 + assert store.records == [] + assert adapter.authenticate_calls == 0 + assert adapter.parse_calls == 0 + + +def test_receive_webhook_event_returns_404_for_provider_type_mismatch( + monkeypatch, +) -> None: + integration_id = uuid4() + store = _Store( + integration=SimpleNamespace( + webhook_type=WebhookType.GITHUB, active=True + ) + ) + adapter = _Adapter() + _install_dependencies(monkeypatch, store, adapter) + + with pytest.raises(HTTPException) as error: + _receive(integration_id) + + assert error.value.status_code == status.HTTP_404_NOT_FOUND + assert store.secret_requests == 0 + assert store.records == [] + assert adapter.authenticate_calls == 0 + assert adapter.parse_calls == 0 + + +def test_receive_webhook_event_records_auth_failure_for_active_integration( + monkeypatch, +) -> None: + integration_id = uuid4() + store = _Store( + integration=SimpleNamespace( + webhook_type=WebhookType.CUSTOM, active=True + ) + ) + adapter = _Adapter(auth_error=WebhookAuthenticationError("bad auth")) + _install_dependencies(monkeypatch, store, adapter) + + with pytest.raises(HTTPException) as error: + _receive(integration_id) + + assert error.value.status_code == status.HTTP_401_UNAUTHORIZED + assert store.secret_requests == 1 + assert len(store.records) == 1 + recorded_id, update = store.records[0] + assert recorded_id == integration_id + assert update.auth_failed is True + assert update.error_summary == "bad auth" + assert adapter.authenticate_calls == 1 + assert adapter.parse_calls == 0 + + +def test_receive_webhook_event_does_not_record_auth_failure_for_inactive_integration( + monkeypatch, +) -> None: + integration_id = uuid4() + store = _Store( + integration=SimpleNamespace( + webhook_type=WebhookType.CUSTOM, active=False + ) + ) + adapter = _Adapter(auth_error=WebhookAuthenticationError("bad auth")) + _install_dependencies(monkeypatch, store, adapter) + + with pytest.raises(HTTPException) as error: + _receive(integration_id) + + assert error.value.status_code == status.HTTP_401_UNAUTHORIZED + assert store.secret_requests == 1 + assert store.records == [] + assert adapter.authenticate_calls == 1 + assert adapter.parse_calls == 0 + + +def test_receive_webhook_event_returns_409_for_inactive_integration_after_auth( + monkeypatch, +) -> None: + integration_id = uuid4() + store = _Store( + integration=SimpleNamespace( + webhook_type=WebhookType.CUSTOM, active=False + ) + ) + adapter = _Adapter() + _install_dependencies(monkeypatch, store, adapter) + + with pytest.raises(HTTPException) as error: + _receive(integration_id) + + assert error.value.status_code == status.HTTP_409_CONFLICT + assert store.secret_requests == 1 + assert store.records == [] + assert adapter.authenticate_calls == 1 + assert adapter.parse_calls == 0 + + +def test_receive_webhook_event_records_invalid_payload_after_auth( + monkeypatch, +) -> None: + integration_id = uuid4() + store = _Store( + integration=SimpleNamespace( + webhook_type=WebhookType.CUSTOM, active=True + ) + ) + adapter = _Adapter(payload_error=WebhookPayloadError("bad payload")) + _install_dependencies(monkeypatch, store, adapter) + + with pytest.raises(HTTPException) as error: + _receive(integration_id) + + assert error.value.status_code == status.HTTP_400_BAD_REQUEST + assert store.secret_requests == 1 + assert len(store.records) == 1 + recorded_id, update = store.records[0] + assert recorded_id == integration_id + assert update.invalid_payload is True + assert update.error_summary == "bad payload" + assert adapter.authenticate_calls == 1 + assert adapter.parse_calls == 1 + + +def test_receive_webhook_event_records_accepted_event(monkeypatch) -> None: + integration_id = uuid4() + store = _Store( + integration=SimpleNamespace( + webhook_type=WebhookType.CUSTOM, active=True + ) + ) + adapter = _Adapter() + _install_dependencies(monkeypatch, store, adapter) + + response = _receive(integration_id) + + assert response.status_code == status.HTTP_202_ACCEPTED + assert store.secret_requests == 1 + assert len(store.records) == 1 + recorded_id, update = store.records[0] + assert recorded_id == integration_id + assert update.accepted is True + assert adapter.authenticate_calls == 1 + assert adapter.parse_calls == 1 diff --git a/tests/unit/zen_stores/schemas/test_webhook_integration_schemas.py b/tests/unit/zen_stores/schemas/test_webhook_integration_schemas.py new file mode 100644 index 00000000000..7979534d063 --- /dev/null +++ b/tests/unit/zen_stores/schemas/test_webhook_integration_schemas.py @@ -0,0 +1,101 @@ +from datetime import datetime +from uuid import uuid4 + +import pytest +from pydantic import ValidationError + +from zenml.constants import API, VERSION_1, WEBHOOKS +from zenml.enums import WebhookType +from zenml.models import WebhookEventStatsUpdate +from zenml.zen_stores.schemas.webhook_integration_schemas import ( + WebhookIntegrationSchema, +) + + +def _webhook_integration_schema() -> WebhookIntegrationSchema: + return WebhookIntegrationSchema( + id=uuid4(), + name="github-intake", + project_id=uuid4(), + user_id=uuid4(), + secret_id=uuid4(), + webhook_type=WebhookType.GITHUB.value, + active=True, + received_count=3, + accepted_count=1, + auth_failed_count=1, + invalid_payload_count=1, + last_received_at=datetime(2026, 7, 9, 8, 0, 0), + last_accepted_at=datetime(2026, 7, 9, 8, 1, 0), + last_error_at=datetime(2026, 7, 9, 8, 2, 0), + last_error_summary="Invalid webhook signature.", + ) + + +@pytest.mark.parametrize( + "update", + [ + WebhookEventStatsUpdate(accepted=True), + WebhookEventStatsUpdate(auth_failed=True, error_summary="bad auth"), + WebhookEventStatsUpdate( + invalid_payload=True, error_summary="bad payload" + ), + ], +) +def test_webhook_event_stats_update_accepts_single_outcome( + update: WebhookEventStatsUpdate, +) -> None: + assert ( + sum([update.accepted, update.auth_failed, update.invalid_payload]) == 1 + ) + + +@pytest.mark.parametrize( + "kwargs", + [ + {}, + {"accepted": True, "auth_failed": True}, + {"accepted": True, "error_summary": "unexpected"}, + ], +) +def test_webhook_event_stats_update_rejects_invalid_outcome( + kwargs: dict[str, object], +) -> None: + with pytest.raises(ValidationError): + WebhookEventStatsUpdate(**kwargs) + + +def test_webhook_integration_schema_to_model_includes_body_and_metadata() -> ( + None +): + schema = _webhook_integration_schema() + + response = schema.to_model(include_metadata=True) + + assert response.id == schema.id + assert response.name == "github-intake" + assert response.webhook_type == WebhookType.GITHUB + assert response.active is True + assert response.endpoint_path == ( + f"{API}{VERSION_1}{WEBHOOKS}/{WebhookType.GITHUB.value}/" + f"{schema.id}/events" + ) + assert response.stats.received_count == 3 + assert response.stats.accepted_count == 1 + assert response.stats.auth_failed_count == 1 + assert response.stats.invalid_payload_count == 1 + assert response.stats.last_received_at == datetime(2026, 7, 9, 8, 0, 0) + assert response.stats.last_accepted_at == datetime(2026, 7, 9, 8, 1, 0) + assert response.stats.last_error_at == datetime(2026, 7, 9, 8, 2, 0) + assert response.stats.last_error_summary == "Invalid webhook signature." + + +def test_webhook_integration_schema_to_model_can_include_empty_resources() -> ( + None +): + schema = _webhook_integration_schema() + schema.user = None + + response = schema.to_model(include_resources=True) + + assert response.get_resources().user is None From a9c1fa5566d00a127aeb1dfbc68ef27e2116afe2 Mon Sep 17 00:00:00 2001 From: Iason Andriopoulos Date: Thu, 9 Jul 2026 14:29:00 +0300 Subject: [PATCH 04/18] Add functional tests --- .../functional/webhooks/__init__.py | 1 + .../functional/webhooks/test_cli.py | 109 +++++++++ .../functional/webhooks/test_client.py | 92 +++++++ .../webhooks/test_events_endpoint.py | 225 ++++++++++++++++++ .../functional/webhooks/test_zen_store.py | 103 ++++++++ 5 files changed, 530 insertions(+) create mode 100644 tests/integration/functional/webhooks/__init__.py create mode 100644 tests/integration/functional/webhooks/test_cli.py create mode 100644 tests/integration/functional/webhooks/test_client.py create mode 100644 tests/integration/functional/webhooks/test_events_endpoint.py create mode 100644 tests/integration/functional/webhooks/test_zen_store.py diff --git a/tests/integration/functional/webhooks/__init__.py b/tests/integration/functional/webhooks/__init__.py new file mode 100644 index 00000000000..644506ccc39 --- /dev/null +++ b/tests/integration/functional/webhooks/__init__.py @@ -0,0 +1 @@ +"""Functional tests for webhook integrations.""" diff --git a/tests/integration/functional/webhooks/test_cli.py b/tests/integration/functional/webhooks/test_cli.py new file mode 100644 index 00000000000..a62a88973ac --- /dev/null +++ b/tests/integration/functional/webhooks/test_cli.py @@ -0,0 +1,109 @@ +import pytest + +from tests.cli_runner_utils import cli_runner +from tests.integration.functional.utils import sample_name +from zenml.cli.cli import cli +from zenml.client import Client +from zenml.enums import WebhookType + +webhook_integration_command = cli.commands["webhook-integration"] +create_command = webhook_integration_command.commands["create"] +describe_command = webhook_integration_command.commands["describe"] +list_command = webhook_integration_command.commands["list"] +update_command = webhook_integration_command.commands["update"] +rotate_secret_command = webhook_integration_command.commands["rotate-secret"] +delete_command = webhook_integration_command.commands["delete"] + + +def _delete_if_exists(name_or_id: str) -> None: + client = Client() + try: + client.delete_webhook_integration(name_or_id) + except KeyError: + pass + + +def test_webhook_integration_cli_lifecycle(clean_client): + runner = cli_runner() + name = sample_name("webhook-cli") + updated_name = sample_name("webhook-cli-updated") + + try: + result = runner.invoke( + create_command, + [name, "--type", WebhookType.CUSTOM.value], + ) + + assert result.exit_code == 0, result.output + assert "Signing secret:" in result.output + + integration = clean_client.get_webhook_integration(name) + assert integration.webhook_type == WebhookType.CUSTOM + assert integration.active is True + + result = runner.invoke(list_command) + + assert result.exit_code == 0, result.output + assert name in result.output + + result = runner.invoke(describe_command, [name]) + + assert result.exit_code == 0, result.output + assert name in result.output + assert integration.endpoint_path in result.output + + result = runner.invoke( + update_command, + [name, "--name", updated_name, "--inactive"], + ) + + assert result.exit_code == 0, result.output + updated = clean_client.get_webhook_integration(updated_name) + assert updated.id == integration.id + assert updated.active is False + + result = runner.invoke( + rotate_secret_command, + [updated_name, "--secret", "replacement-secret"], + ) + + assert result.exit_code == 0, result.output + assert "Signing secret: replacement-secret" in result.output + + result = runner.invoke(delete_command, [updated_name, "--yes"]) + + assert result.exit_code == 0, result.output + with pytest.raises(KeyError): + clean_client.get_webhook_integration(updated.id) + finally: + _delete_if_exists(updated_name) + _delete_if_exists(name) + + +def test_webhook_integration_cli_does_not_echo_user_supplied_secret( + clean_client, +): + runner = cli_runner() + name = sample_name("webhook-cli-secret") + + try: + result = runner.invoke( + create_command, + [ + name, + "--type", + WebhookType.GITHUB.value, + "--secret", + "user-supplied-secret", + ], + ) + + assert result.exit_code == 0, result.output + assert "user-supplied-secret" not in result.output + assert "Signing secret:" not in result.output + + integration = clean_client.get_webhook_integration(name) + assert integration.webhook_type == WebhookType.GITHUB + assert "secret" not in integration.model_dump() + finally: + _delete_if_exists(name) diff --git a/tests/integration/functional/webhooks/test_client.py b/tests/integration/functional/webhooks/test_client.py new file mode 100644 index 00000000000..abfcfcf6aa4 --- /dev/null +++ b/tests/integration/functional/webhooks/test_client.py @@ -0,0 +1,92 @@ +import pytest + +from tests.integration.functional.utils import sample_name +from zenml.enums import WebhookType + + +def test_client_webhook_integration_lifecycle(clean_client): + name = sample_name("webhook-client") + + result = clean_client.create_webhook_integration( + name=name, + webhook_type=WebhookType.CUSTOM, + ) + + assert result.secret is not None + integration = result.integration + assert integration.name == name + assert integration.webhook_type == WebhookType.CUSTOM + assert integration.active is True + assert integration.project_id == clean_client.active_project.id + assert integration.endpoint_path.endswith( + f"/custom/{integration.id}/events" + ) + assert integration.stats.received_count == 0 + + by_id = clean_client.get_webhook_integration(integration.id) + by_name = clean_client.get_webhook_integration(name) + + assert by_id.id == integration.id + assert by_name.id == integration.id + assert "secret" not in by_id.model_dump() + + listed_by_type = clean_client.list_webhook_integrations( + webhook_type=WebhookType.CUSTOM + ) + listed_by_active_state = clean_client.list_webhook_integrations( + active=True + ) + + assert integration.id in {item.id for item in listed_by_type.items} + assert integration.id in {item.id for item in listed_by_active_state.items} + + updated_name = sample_name("webhook-client-updated") + updated = clean_client.update_webhook_integration( + name_id_or_prefix=name, + name=updated_name, + active=False, + ) + + assert updated.id == integration.id + assert updated.name == updated_name + assert updated.active is False + + inactive_integrations = clean_client.list_webhook_integrations( + active=False + ) + + assert integration.id in {item.id for item in inactive_integrations.items} + + rotated = clean_client.rotate_webhook_integration_secret( + name_id_or_prefix=updated_name, + secret="replacement-secret", + ) + + assert rotated.secret.get_secret_value() == "replacement-secret" + + clean_client.delete_webhook_integration(updated_name) + + with pytest.raises(KeyError): + clean_client.get_webhook_integration(integration.id) + + +def test_client_does_not_echo_user_supplied_webhook_secret(clean_client): + name = sample_name("webhook-client-secret") + + result = clean_client.create_webhook_integration( + name=name, + webhook_type=WebhookType.GITHUB, + secret="user-supplied-secret", + ) + + try: + assert result.secret is None + + integration = clean_client.get_webhook_integration( + result.integration.id + ) + + assert "secret" not in integration.model_dump() + assert integration.webhook_type == WebhookType.GITHUB + finally: + clean_client.delete_webhook_integration(result.integration.id) diff --git a/tests/integration/functional/webhooks/test_events_endpoint.py b/tests/integration/functional/webhooks/test_events_endpoint.py new file mode 100644 index 00000000000..82188f8afb6 --- /dev/null +++ b/tests/integration/functional/webhooks/test_events_endpoint.py @@ -0,0 +1,225 @@ +import hashlib +import hmac +from uuid import uuid4 + +import pytest +import requests + +from tests.integration.functional.utils import sample_name +from zenml.enums import WebhookType +from zenml.zen_stores.rest_zen_store import RestZenStore + + +def _require_rest_store(clean_client) -> RestZenStore: + store = clean_client.zen_store + if not isinstance(store, RestZenStore): + pytest.skip("Webhook intake endpoint tests require a REST store.") + return store + + +def _signature(secret: str, body: bytes) -> str: + return ( + "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() + ) + + +def _post_webhook( + store: RestZenStore, + endpoint_path: str, + body: bytes, + headers: dict[str, str], +) -> requests.Response: + return requests.post( + store.url + endpoint_path, + data=body, + headers=headers, + timeout=31, + ) + + +def test_webhook_intake_accepts_valid_custom_delivery(clean_client): + store = _require_rest_store(clean_client) + result = clean_client.create_webhook_integration( + name=sample_name("webhook-intake-valid"), + webhook_type=WebhookType.CUSTOM, + ) + integration = result.integration + assert result.secret is not None + secret = result.secret.get_secret_value() + body = b'{"pipeline":"training"}' + + try: + response = _post_webhook( + store=store, + endpoint_path=integration.endpoint_path, + body=body, + headers={ + "X-ZenML-Event": "pipeline.ready", + "X-ZenML-Delivery": "delivery-1", + "X-ZenML-Signature-256": _signature(secret, body), + }, + ) + + assert response.status_code == 202 + assert response.content == b"" + + updated = clean_client.get_webhook_integration(integration.id) + assert updated.stats.received_count == 1 + assert updated.stats.accepted_count == 1 + assert updated.stats.auth_failed_count == 0 + assert updated.stats.invalid_payload_count == 0 + assert updated.stats.last_received_at is not None + assert updated.stats.last_accepted_at is not None + finally: + clean_client.delete_webhook_integration(integration.id) + + +def test_webhook_intake_records_auth_failures_for_active_integrations( + clean_client, +): + store = _require_rest_store(clean_client) + result = clean_client.create_webhook_integration( + name=sample_name("webhook-intake-auth"), + webhook_type=WebhookType.CUSTOM, + ) + integration = result.integration + body = b'{"pipeline":"training"}' + + try: + response = _post_webhook( + store=store, + endpoint_path=integration.endpoint_path, + body=body, + headers={ + "X-ZenML-Event": "pipeline.ready", + "X-ZenML-Signature-256": "sha256=invalid", + }, + ) + + assert response.status_code == 401 + + updated = clean_client.get_webhook_integration(integration.id) + assert updated.stats.received_count == 1 + assert updated.stats.accepted_count == 0 + assert updated.stats.auth_failed_count == 1 + assert updated.stats.invalid_payload_count == 0 + assert updated.stats.last_error_at is not None + assert updated.stats.last_error_summary == "Invalid webhook signature." + finally: + clean_client.delete_webhook_integration(integration.id) + + +def test_webhook_intake_records_invalid_payload_after_auth(clean_client): + store = _require_rest_store(clean_client) + result = clean_client.create_webhook_integration( + name=sample_name("webhook-intake-payload"), + webhook_type=WebhookType.CUSTOM, + ) + integration = result.integration + assert result.secret is not None + secret = result.secret.get_secret_value() + body = b"not-json" + + try: + response = _post_webhook( + store=store, + endpoint_path=integration.endpoint_path, + body=body, + headers={ + "X-ZenML-Event": "pipeline.ready", + "X-ZenML-Signature-256": _signature(secret, body), + }, + ) + + assert response.status_code == 400 + + updated = clean_client.get_webhook_integration(integration.id) + assert updated.stats.received_count == 1 + assert updated.stats.accepted_count == 0 + assert updated.stats.auth_failed_count == 0 + assert updated.stats.invalid_payload_count == 1 + assert updated.stats.last_error_at is not None + assert ( + updated.stats.last_error_summary + == "Request body must be valid JSON." + ) + finally: + clean_client.delete_webhook_integration(integration.id) + + +def test_webhook_intake_rejects_inactive_integration_without_stats( + clean_client, +): + store = _require_rest_store(clean_client) + result = clean_client.create_webhook_integration( + name=sample_name("webhook-intake-inactive"), + webhook_type=WebhookType.CUSTOM, + active=False, + ) + integration = result.integration + assert result.secret is not None + secret = result.secret.get_secret_value() + body = b'{"pipeline":"training"}' + + try: + response = _post_webhook( + store=store, + endpoint_path=integration.endpoint_path, + body=body, + headers={ + "X-ZenML-Event": "pipeline.ready", + "X-ZenML-Signature-256": _signature(secret, body), + }, + ) + + assert response.status_code == 409 + + updated = clean_client.get_webhook_integration(integration.id) + assert updated.stats.received_count == 0 + assert updated.stats.accepted_count == 0 + assert updated.stats.auth_failed_count == 0 + assert updated.stats.invalid_payload_count == 0 + finally: + clean_client.delete_webhook_integration(integration.id) + + +def test_webhook_intake_does_not_record_type_mismatch(clean_client): + store = _require_rest_store(clean_client) + result = clean_client.create_webhook_integration( + name=sample_name("webhook-intake-mismatch"), + webhook_type=WebhookType.CUSTOM, + ) + integration = result.integration + endpoint_path = integration.endpoint_path.replace("/custom/", "/github/") + + try: + response = _post_webhook( + store=store, + endpoint_path=endpoint_path, + body=b'{"pipeline":"training"}', + headers={}, + ) + + assert response.status_code == 404 + + updated = clean_client.get_webhook_integration(integration.id) + assert updated.stats.received_count == 0 + assert updated.stats.accepted_count == 0 + assert updated.stats.auth_failed_count == 0 + assert updated.stats.invalid_payload_count == 0 + finally: + clean_client.delete_webhook_integration(integration.id) + + +def test_webhook_intake_returns_not_found_for_unknown_integration( + clean_client, +): + store = _require_rest_store(clean_client) + response = _post_webhook( + store=store, + endpoint_path=f"/api/v1/webhooks/custom/{uuid4()}/events", + body=b'{"pipeline":"training"}', + headers={}, + ) + + assert response.status_code == 404 diff --git a/tests/integration/functional/webhooks/test_zen_store.py b/tests/integration/functional/webhooks/test_zen_store.py new file mode 100644 index 00000000000..168f38f2578 --- /dev/null +++ b/tests/integration/functional/webhooks/test_zen_store.py @@ -0,0 +1,103 @@ +import pytest + +from tests.integration.functional.utils import sample_name +from zenml.enums import WebhookType +from zenml.models import ( + WebhookIntegrationFilter, + WebhookIntegrationRequest, + WebhookIntegrationSecretRequest, + WebhookIntegrationUpdate, +) + + +def test_zen_store_webhook_integration_lifecycle(clean_client): + store = clean_client.zen_store + project_id = clean_client.active_project.id + name = sample_name("webhook-store") + + result = store.create_webhook_integration( + WebhookIntegrationRequest( + project=project_id, + name=name, + webhook_type=WebhookType.CUSTOM, + ) + ) + + integration = result.integration + + assert result.secret is not None + assert integration.name == name + assert integration.project_id == project_id + assert integration.webhook_type == WebhookType.CUSTOM + assert integration.active is True + assert integration.stats.received_count == 0 + + by_id = store.get_webhook_integration(integration.id) + + assert by_id.id == integration.id + assert by_id.stats.received_count == 0 + + filtered = store.list_webhook_integrations( + WebhookIntegrationFilter( + project=project_id, + webhook_type=WebhookType.CUSTOM, + active=True, + ), + hydrate=True, + ) + + assert integration.id in {item.id for item in filtered.items} + assert all(item.stats.received_count == 0 for item in filtered.items) + + updated_name = sample_name("webhook-store-updated") + updated = store.update_webhook_integration( + integration_id=integration.id, + update=WebhookIntegrationUpdate(name=updated_name, active=False), + ) + + assert updated.id == integration.id + assert updated.name == updated_name + assert updated.active is False + + inactive_integrations = store.list_webhook_integrations( + WebhookIntegrationFilter(project=project_id, active=False) + ) + + assert integration.id in {item.id for item in inactive_integrations.items} + + rotated = store.rotate_webhook_integration_secret( + integration_id=integration.id, + request=WebhookIntegrationSecretRequest(secret="replacement-secret"), + ) + + assert rotated.secret.get_secret_value() == "replacement-secret" + + store.delete_webhook_integration(integration.id) + + with pytest.raises(KeyError): + store.get_webhook_integration(integration.id) + + +def test_zen_store_does_not_echo_user_supplied_webhook_secret(clean_client): + store = clean_client.zen_store + project_id = clean_client.active_project.id + name = sample_name("webhook-store-secret") + + result = store.create_webhook_integration( + WebhookIntegrationRequest( + project=project_id, + name=name, + webhook_type=WebhookType.GITHUB, + secret="user-supplied-secret", + ) + ) + + try: + assert result.secret is None + + integration = store.get_webhook_integration(result.integration.id) + + assert "secret" not in integration.model_dump() + assert integration.webhook_type == WebhookType.GITHUB + finally: + store.delete_webhook_integration(result.integration.id) From ef8107e4956b32ac8cd25ce097f25246926bfeae Mon Sep 17 00:00:00 2001 From: Iason Andriopoulos Date: Thu, 9 Jul 2026 15:04:07 +0300 Subject: [PATCH 05/18] Guard against empty secrets --- .../models/v2/core/webhook_integration.py | 9 ++-- src/zenml/utils/secret_utils.py | 29 ++++++++++- tests/unit/utils/test_secret_utils.py | 29 ++++++++++- .../test_webhook_integration_schemas.py | 48 ++++++++++++++++++- 4 files changed, 109 insertions(+), 6 deletions(-) diff --git a/src/zenml/models/v2/core/webhook_integration.py b/src/zenml/models/v2/core/webhook_integration.py index 6db549aee21..6bd8aecca94 100644 --- a/src/zenml/models/v2/core/webhook_integration.py +++ b/src/zenml/models/v2/core/webhook_integration.py @@ -30,7 +30,10 @@ ProjectScopedResponseMetadata, ProjectScopedResponseResources, ) -from zenml.utils.secret_utils import PlainSerializedSecretStr +from zenml.utils.secret_utils import ( + NonEmptyPlainSerializedSecretStr, + PlainSerializedSecretStr, +) class WebhookIntegrationRequest(ProjectScopedRequest): @@ -39,7 +42,7 @@ class WebhookIntegrationRequest(ProjectScopedRequest): name: str = Field(max_length=STR_FIELD_MAX_LENGTH) webhook_type: WebhookType active: bool = True - secret: PlainSerializedSecretStr | None = Field( + secret: NonEmptyPlainSerializedSecretStr | None = Field( default=None, description="Optional signing secret. One is generated when omitted.", ) @@ -166,7 +169,7 @@ class WebhookIntegrationCreateResponse(BaseZenModel): class WebhookIntegrationSecretRequest(BaseZenModel): """Request model for rotating a webhook integration secret.""" - secret: PlainSerializedSecretStr | None = None + secret: NonEmptyPlainSerializedSecretStr | None = None class WebhookIntegrationSecretResponse(BaseZenModel): diff --git a/src/zenml/utils/secret_utils.py b/src/zenml/utils/secret_utils.py index 744675376d0..c5ae739fe60 100644 --- a/src/zenml/utils/secret_utils.py +++ b/src/zenml/utils/secret_utils.py @@ -16,7 +16,7 @@ import re from typing import TYPE_CHECKING, Any, List, NamedTuple, Union -from pydantic import Field, PlainSerializer, SecretStr +from pydantic import AfterValidator, Field, PlainSerializer, SecretStr from typing_extensions import Annotated from zenml.logger import get_logger @@ -39,6 +39,33 @@ ), ] + +def _validate_non_empty_secret(secret: SecretStr) -> SecretStr: + """Validate that a secret contains non-whitespace content. + + Args: + secret: The secret to validate. + + Returns: + The validated secret. + + Raises: + ValueError: If the secret is empty or contains only whitespace. + """ + if not secret.get_secret_value().strip(): + raise ValueError("Secret must not be empty.") + return secret + + +NonEmptyPlainSerializedSecretStr = Annotated[ + SecretStr, + AfterValidator(_validate_non_empty_secret), + PlainSerializer( + lambda v: v.get_secret_value() if v is not None else None, + when_used="json", + ), +] + logger = get_logger(__name__) diff --git a/tests/unit/utils/test_secret_utils.py b/tests/unit/utils/test_secret_utils.py index 3c5e848f0aa..26ecca209b0 100644 --- a/tests/unit/utils/test_secret_utils.py +++ b/tests/unit/utils/test_secret_utils.py @@ -11,10 +11,12 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express # or implied. See the License for the specific language governing # permissions and limitations under the License. +"""Unit tests for secret utilities.""" +import pytest from hypothesis import given from hypothesis.strategies import from_regex -from pydantic import BaseModel, Field, SecretStr +from pydantic import BaseModel, Field, SecretStr, ValidationError from zenml.utils import secret_utils @@ -90,3 +92,28 @@ class Model(BaseModel): secret_utils.is_secret_field(Model.model_fields["non_secret"]) is False ) assert secret_utils.is_secret_field(Model.model_fields["secret"]) is True + + +@pytest.mark.parametrize("value", ["", " ", "\t\n"]) +def test_non_empty_plain_serialized_secret_str_rejects_empty_values( + value: str, +) -> None: + """Tests that non-empty serialized secrets reject blank values.""" + + class Model(BaseModel): + secret: secret_utils.NonEmptyPlainSerializedSecretStr + + with pytest.raises(ValidationError): + Model(secret=value) + + +def test_non_empty_plain_serialized_secret_str_serializes_plain_value() -> None: + """Tests that non-empty serialized secrets preserve plain JSON output.""" + + class Model(BaseModel): + secret: secret_utils.NonEmptyPlainSerializedSecretStr + + model = Model(secret=" value ") + + assert model.secret.get_secret_value() == " value " + assert model.model_dump_json() == '{"secret":" value "}' diff --git a/tests/unit/zen_stores/schemas/test_webhook_integration_schemas.py b/tests/unit/zen_stores/schemas/test_webhook_integration_schemas.py index 7979534d063..c39e84a246f 100644 --- a/tests/unit/zen_stores/schemas/test_webhook_integration_schemas.py +++ b/tests/unit/zen_stores/schemas/test_webhook_integration_schemas.py @@ -1,3 +1,5 @@ +"""Unit tests for webhook integration schemas and request models.""" + from datetime import datetime from uuid import uuid4 @@ -6,7 +8,11 @@ from zenml.constants import API, VERSION_1, WEBHOOKS from zenml.enums import WebhookType -from zenml.models import WebhookEventStatsUpdate +from zenml.models import ( + WebhookEventStatsUpdate, + WebhookIntegrationRequest, + WebhookIntegrationSecretRequest, +) from zenml.zen_stores.schemas.webhook_integration_schemas import ( WebhookIntegrationSchema, ) @@ -45,6 +51,7 @@ def _webhook_integration_schema() -> WebhookIntegrationSchema: def test_webhook_event_stats_update_accepts_single_outcome( update: WebhookEventStatsUpdate, ) -> None: + """Webhook stats updates accept exactly one terminal outcome.""" assert ( sum([update.accepted, update.auth_failed, update.invalid_payload]) == 1 ) @@ -61,13 +68,51 @@ def test_webhook_event_stats_update_accepts_single_outcome( def test_webhook_event_stats_update_rejects_invalid_outcome( kwargs: dict[str, object], ) -> None: + """Webhook stats updates reject missing or conflicting outcomes.""" with pytest.raises(ValidationError): WebhookEventStatsUpdate(**kwargs) +@pytest.mark.parametrize("secret", ["", " "]) +def test_webhook_integration_request_rejects_empty_secret( + secret: str, +) -> None: + """Webhook integration creation rejects empty signing secrets.""" + with pytest.raises(ValidationError): + WebhookIntegrationRequest( + name="github-intake", + project=uuid4(), + webhook_type=WebhookType.GITHUB, + secret=secret, + ) + + +@pytest.mark.parametrize("secret", ["", " "]) +def test_webhook_integration_secret_request_rejects_empty_secret( + secret: str, +) -> None: + """Webhook integration secret rotation rejects empty signing secrets.""" + with pytest.raises(ValidationError): + WebhookIntegrationSecretRequest(secret=secret) + + +def test_webhook_integration_requests_allow_missing_secret() -> None: + """Webhook integration requests allow missing secrets for generation.""" + integration_request = WebhookIntegrationRequest( + name="github-intake", + project=uuid4(), + webhook_type=WebhookType.GITHUB, + ) + secret_request = WebhookIntegrationSecretRequest() + + assert integration_request.secret is None + assert secret_request.secret is None + + def test_webhook_integration_schema_to_model_includes_body_and_metadata() -> ( None ): + """Webhook integration schemas include body and stats metadata.""" schema = _webhook_integration_schema() response = schema.to_model(include_metadata=True) @@ -93,6 +138,7 @@ def test_webhook_integration_schema_to_model_includes_body_and_metadata() -> ( def test_webhook_integration_schema_to_model_can_include_empty_resources() -> ( None ): + """Webhook integration schemas can include empty resources.""" schema = _webhook_integration_schema() schema.user = None From 62790ba7c900fdba0156798c14a80f63cae76153 Mon Sep 17 00:00:00 2001 From: Iason Andriopoulos Date: Thu, 9 Jul 2026 15:20:06 +0300 Subject: [PATCH 06/18] Use nested stats instead of flat stats --- .../7c0d9e4a1b2f_add_webhook_integrations.py | 11 +--- .../schemas/webhook_integration_schemas.py | 41 +++++++-------- src/zenml/zen_stores/sql_zen_store.py | 51 +++++++++---------- tests/unit/utils/test_secret_utils.py | 4 +- .../test_webhook_integration_schemas.py | 44 +++++++++++++--- 5 files changed, 87 insertions(+), 64 deletions(-) diff --git a/src/zenml/zen_stores/migrations/versions/7c0d9e4a1b2f_add_webhook_integrations.py b/src/zenml/zen_stores/migrations/versions/7c0d9e4a1b2f_add_webhook_integrations.py index dc24e8a7d15..4418a7767b6 100644 --- a/src/zenml/zen_stores/migrations/versions/7c0d9e4a1b2f_add_webhook_integrations.py +++ b/src/zenml/zen_stores/migrations/versions/7c0d9e4a1b2f_add_webhook_integrations.py @@ -31,17 +31,10 @@ def upgrade() -> None: "webhook_type", sqlmodel.sql.sqltypes.AutoString(), nullable=False ), sa.Column("active", sa.Boolean(), nullable=False), - sa.Column("received_count", sa.Integer(), nullable=False), - sa.Column("accepted_count", sa.Integer(), nullable=False), - sa.Column("auth_failed_count", sa.Integer(), nullable=False), - sa.Column("invalid_payload_count", sa.Integer(), nullable=False), - sa.Column("last_received_at", sa.DateTime(), nullable=True), - sa.Column("last_accepted_at", sa.DateTime(), nullable=True), - sa.Column("last_error_at", sa.DateTime(), nullable=True), sa.Column( - "last_error_summary", + "stats", sa.TEXT(), - nullable=True, + nullable=False, ), sa.ForeignKeyConstraint( ["project_id"], diff --git a/src/zenml/zen_stores/schemas/webhook_integration_schemas.py b/src/zenml/zen_stores/schemas/webhook_integration_schemas.py index c7646cdd2b0..bd6ddfa4b0c 100644 --- a/src/zenml/zen_stores/schemas/webhook_integration_schemas.py +++ b/src/zenml/zen_stores/schemas/webhook_integration_schemas.py @@ -5,7 +5,6 @@ # You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 """SQL schema for webhook integrations.""" -from datetime import datetime from typing import Any from uuid import UUID @@ -72,17 +71,28 @@ class WebhookIntegrationSchema(NamedSchema, table=True): ) webhook_type: str = Field(index=True) active: bool = Field(default=True, index=True) - received_count: int = 0 - accepted_count: int = 0 - auth_failed_count: int = 0 - invalid_payload_count: int = 0 - last_received_at: datetime | None = None - last_accepted_at: datetime | None = None - last_error_at: datetime | None = None - last_error_summary: str | None = Field( - default=None, sa_column=Column(TEXT, nullable=True) + stats: str = Field( + default=WebhookIntegrationStats().model_dump_json(), + sa_column=Column(TEXT, nullable=False), ) + @property + def parsed_stats(self) -> WebhookIntegrationStats: + """Parse persisted intake statistics. + + Returns: + The typed intake statistics. + """ + return WebhookIntegrationStats.model_validate_json(self.stats or "{}") + + def set_stats(self, stats: WebhookIntegrationStats) -> None: + """Persist typed intake statistics. + + Args: + stats: The typed intake statistics to persist. + """ + self.stats = stats.model_dump_json() + @classmethod def from_request( cls, request: WebhookIntegrationRequest, secret_id: UUID @@ -124,16 +134,7 @@ def to_model( metadata = None if include_metadata: metadata = WebhookIntegrationResponseMetadata( - stats=WebhookIntegrationStats( - received_count=self.received_count, - accepted_count=self.accepted_count, - auth_failed_count=self.auth_failed_count, - invalid_payload_count=self.invalid_payload_count, - last_received_at=self.last_received_at, - last_accepted_at=self.last_accepted_at, - last_error_at=self.last_error_at, - last_error_summary=self.last_error_summary, - ) + stats=self.parsed_stats ) resources = None diff --git a/src/zenml/zen_stores/sql_zen_store.py b/src/zenml/zen_stores/sql_zen_store.py index f26c89e0cb1..49c9aadebc4 100644 --- a/src/zenml/zen_stores/sql_zen_store.py +++ b/src/zenml/zen_stores/sql_zen_store.py @@ -8343,7 +8343,7 @@ def get_webhook_integration_secret(self, integration_id: UUID) -> str: def record_webhook_event( self, integration_id: UUID, update: WebhookEventStatsUpdate ) -> None: - """Atomically record a webhook intake outcome. + """Record a webhook intake outcome. Args: integration_id: The webhook integration ID. @@ -8353,36 +8353,35 @@ def record_webhook_event( KeyError: If the webhook integration no longer exists. """ now = utc_now() - values: dict[str, Any] = { - "received_count": WebhookIntegrationSchema.received_count + 1, - "last_received_at": now, - } - if update.accepted: - values["accepted_count"] = ( - WebhookIntegrationSchema.accepted_count + 1 - ) - values["last_accepted_at"] = now - elif update.auth_failed: - values["auth_failed_count"] = ( - WebhookIntegrationSchema.auth_failed_count + 1 - ) - elif update.invalid_payload: - values["invalid_payload_count"] = ( - WebhookIntegrationSchema.invalid_payload_count + 1 - ) - if update.error_summary is not None: - values["last_error_at"] = now - values["last_error_summary"] = update.error_summary with Session(self.engine) as session: - result = session.exec( - sqlalchemy.update(WebhookIntegrationSchema) + schema = session.exec( + select(WebhookIntegrationSchema) .where(col(WebhookIntegrationSchema.id) == integration_id) - .values(**values) - ) - if result.rowcount == 0: + .with_for_update() + ).first() + if schema is None: raise KeyError( f"Webhook integration {integration_id} not found." ) + + stats = schema.parsed_stats + stats.received_count += 1 + stats.last_received_at = now + + if update.accepted: + stats.accepted_count += 1 + stats.last_accepted_at = now + elif update.auth_failed: + stats.auth_failed_count += 1 + elif update.invalid_payload: + stats.invalid_payload_count += 1 + + if update.error_summary is not None: + stats.last_error_at = now + stats.last_error_summary = update.error_summary + + schema.set_stats(stats) + session.add(schema) session.commit() # -------------------- Triggers --------------------- diff --git a/tests/unit/utils/test_secret_utils.py b/tests/unit/utils/test_secret_utils.py index 26ecca209b0..05463ee9a1a 100644 --- a/tests/unit/utils/test_secret_utils.py +++ b/tests/unit/utils/test_secret_utils.py @@ -107,7 +107,9 @@ class Model(BaseModel): Model(secret=value) -def test_non_empty_plain_serialized_secret_str_serializes_plain_value() -> None: +def test_non_empty_plain_serialized_secret_str_serializes_plain_value() -> ( + None +): """Tests that non-empty serialized secrets preserve plain JSON output.""" class Model(BaseModel): diff --git a/tests/unit/zen_stores/schemas/test_webhook_integration_schemas.py b/tests/unit/zen_stores/schemas/test_webhook_integration_schemas.py index c39e84a246f..7f6382c6f79 100644 --- a/tests/unit/zen_stores/schemas/test_webhook_integration_schemas.py +++ b/tests/unit/zen_stores/schemas/test_webhook_integration_schemas.py @@ -12,6 +12,7 @@ WebhookEventStatsUpdate, WebhookIntegrationRequest, WebhookIntegrationSecretRequest, + WebhookIntegrationStats, ) from zenml.zen_stores.schemas.webhook_integration_schemas import ( WebhookIntegrationSchema, @@ -27,14 +28,16 @@ def _webhook_integration_schema() -> WebhookIntegrationSchema: secret_id=uuid4(), webhook_type=WebhookType.GITHUB.value, active=True, - received_count=3, - accepted_count=1, - auth_failed_count=1, - invalid_payload_count=1, - last_received_at=datetime(2026, 7, 9, 8, 0, 0), - last_accepted_at=datetime(2026, 7, 9, 8, 1, 0), - last_error_at=datetime(2026, 7, 9, 8, 2, 0), - last_error_summary="Invalid webhook signature.", + stats=WebhookIntegrationStats( + received_count=3, + accepted_count=1, + auth_failed_count=1, + invalid_payload_count=1, + last_received_at=datetime(2026, 7, 9, 8, 0, 0), + last_accepted_at=datetime(2026, 7, 9, 8, 1, 0), + last_error_at=datetime(2026, 7, 9, 8, 2, 0), + last_error_summary="Invalid webhook signature.", + ).model_dump_json(), ) @@ -135,6 +138,31 @@ def test_webhook_integration_schema_to_model_includes_body_and_metadata() -> ( assert response.stats.last_error_summary == "Invalid webhook signature." +def test_webhook_integration_schema_to_model_defaults_missing_stats() -> None: + """Webhook integration schemas default missing stats fields.""" + schema = _webhook_integration_schema() + schema.stats = '{"received_count": 3, "future_count": 7}' + + response = schema.to_model(include_metadata=True) + + assert response.stats.received_count == 3 + assert response.stats.accepted_count == 0 + assert response.stats.auth_failed_count == 0 + assert response.stats.invalid_payload_count == 0 + assert response.stats.last_received_at is None + + +def test_webhook_integration_schema_can_update_serialized_stats() -> None: + """Webhook integration schemas serialize typed stats.""" + schema = _webhook_integration_schema() + stats = WebhookIntegrationStats(received_count=5) + + schema.set_stats(stats) + + assert schema.parsed_stats.received_count == 5 + assert schema.parsed_stats.accepted_count == 0 + + def test_webhook_integration_schema_to_model_can_include_empty_resources() -> ( None ): From 13f82f3b752ff12ae26853387b006de943f8dede Mon Sep 17 00:00:00 2001 From: Iason Andriopoulos Date: Fri, 10 Jul 2026 16:43:38 +0300 Subject: [PATCH 07/18] Fix skipped tests --- src/zenml/cli/webhook_integration.py | 19 +++++- .../functional/webhooks/test_cli.py | 14 ++++- .../webhooks/test_events_endpoint.py | 58 +++++++++---------- 3 files changed, 59 insertions(+), 32 deletions(-) diff --git a/src/zenml/cli/webhook_integration.py b/src/zenml/cli/webhook_integration.py index e58d6a63c85..ef7d8c82ff4 100644 --- a/src/zenml/cli/webhook_integration.py +++ b/src/zenml/cli/webhook_integration.py @@ -11,7 +11,7 @@ from zenml.client import Client from zenml.console import console from zenml.enums import CliCategories, WebhookType -from zenml.models import WebhookIntegrationFilter +from zenml.models import WebhookIntegrationFilter, WebhookIntegrationResponse @cli.group( @@ -77,6 +77,22 @@ def describe_webhook_integration(name_or_id: str) -> None: cli_utils.declare(f"Endpoint path: {integration.endpoint_path}") +def _format_webhook_integration_row( + integration: WebhookIntegrationResponse, + _output_format: OutputFormat, +) -> dict[str, Any]: + """Add the project name to a webhook integration list row. + + Args: + integration: The webhook integration to format. + _output_format: The requested output format. + + Returns: + Additional row values for the webhook integration. + """ + return {"project": integration.project.name} + + @webhook_integration.command("list") @list_options( WebhookIntegrationFilter, @@ -98,6 +114,7 @@ def list_webhook_integrations( integrations, columns, output_format, + row_formatter=_format_webhook_integration_row, empty_message="No webhook integrations found for this filter.", ) diff --git a/tests/integration/functional/webhooks/test_cli.py b/tests/integration/functional/webhooks/test_cli.py index a62a88973ac..fd5dd135523 100644 --- a/tests/integration/functional/webhooks/test_cli.py +++ b/tests/integration/functional/webhooks/test_cli.py @@ -1,5 +1,9 @@ +import io +from unittest.mock import patch + import pytest +import zenml_cli from tests.cli_runner_utils import cli_runner from tests.integration.functional.utils import sample_name from zenml.cli.cli import cli @@ -41,10 +45,16 @@ def test_webhook_integration_cli_lifecycle(clean_client): assert integration.webhook_type == WebhookType.CUSTOM assert integration.active is True - result = runner.invoke(list_command) + list_output_buffer = io.StringIO() + with patch.object( + zenml_cli, "_original_stdout", list_output_buffer + ): + result = runner.invoke(list_command) + list_output = list_output_buffer.getvalue() + result.output assert result.exit_code == 0, result.output - assert name in result.output + assert name in list_output + assert "Unknown column(s) ignored" not in list_output result = runner.invoke(describe_command, [name]) diff --git a/tests/integration/functional/webhooks/test_events_endpoint.py b/tests/integration/functional/webhooks/test_events_endpoint.py index 82188f8afb6..9b99fc43776 100644 --- a/tests/integration/functional/webhooks/test_events_endpoint.py +++ b/tests/integration/functional/webhooks/test_events_endpoint.py @@ -10,8 +10,8 @@ from zenml.zen_stores.rest_zen_store import RestZenStore -def _require_rest_store(clean_client) -> RestZenStore: - store = clean_client.zen_store +def _require_rest_store(clean_project) -> RestZenStore: + store = clean_project.zen_store if not isinstance(store, RestZenStore): pytest.skip("Webhook intake endpoint tests require a REST store.") return store @@ -37,9 +37,9 @@ def _post_webhook( ) -def test_webhook_intake_accepts_valid_custom_delivery(clean_client): - store = _require_rest_store(clean_client) - result = clean_client.create_webhook_integration( +def test_webhook_intake_accepts_valid_custom_delivery(clean_project): + store = _require_rest_store(clean_project) + result = clean_project.create_webhook_integration( name=sample_name("webhook-intake-valid"), webhook_type=WebhookType.CUSTOM, ) @@ -63,7 +63,7 @@ def test_webhook_intake_accepts_valid_custom_delivery(clean_client): assert response.status_code == 202 assert response.content == b"" - updated = clean_client.get_webhook_integration(integration.id) + updated = clean_project.get_webhook_integration(integration.id) assert updated.stats.received_count == 1 assert updated.stats.accepted_count == 1 assert updated.stats.auth_failed_count == 0 @@ -71,14 +71,14 @@ def test_webhook_intake_accepts_valid_custom_delivery(clean_client): assert updated.stats.last_received_at is not None assert updated.stats.last_accepted_at is not None finally: - clean_client.delete_webhook_integration(integration.id) + clean_project.delete_webhook_integration(integration.id) def test_webhook_intake_records_auth_failures_for_active_integrations( - clean_client, + clean_project, ): - store = _require_rest_store(clean_client) - result = clean_client.create_webhook_integration( + store = _require_rest_store(clean_project) + result = clean_project.create_webhook_integration( name=sample_name("webhook-intake-auth"), webhook_type=WebhookType.CUSTOM, ) @@ -98,7 +98,7 @@ def test_webhook_intake_records_auth_failures_for_active_integrations( assert response.status_code == 401 - updated = clean_client.get_webhook_integration(integration.id) + updated = clean_project.get_webhook_integration(integration.id) assert updated.stats.received_count == 1 assert updated.stats.accepted_count == 0 assert updated.stats.auth_failed_count == 1 @@ -106,12 +106,12 @@ def test_webhook_intake_records_auth_failures_for_active_integrations( assert updated.stats.last_error_at is not None assert updated.stats.last_error_summary == "Invalid webhook signature." finally: - clean_client.delete_webhook_integration(integration.id) + clean_project.delete_webhook_integration(integration.id) -def test_webhook_intake_records_invalid_payload_after_auth(clean_client): - store = _require_rest_store(clean_client) - result = clean_client.create_webhook_integration( +def test_webhook_intake_records_invalid_payload_after_auth(clean_project): + store = _require_rest_store(clean_project) + result = clean_project.create_webhook_integration( name=sample_name("webhook-intake-payload"), webhook_type=WebhookType.CUSTOM, ) @@ -133,7 +133,7 @@ def test_webhook_intake_records_invalid_payload_after_auth(clean_client): assert response.status_code == 400 - updated = clean_client.get_webhook_integration(integration.id) + updated = clean_project.get_webhook_integration(integration.id) assert updated.stats.received_count == 1 assert updated.stats.accepted_count == 0 assert updated.stats.auth_failed_count == 0 @@ -144,14 +144,14 @@ def test_webhook_intake_records_invalid_payload_after_auth(clean_client): == "Request body must be valid JSON." ) finally: - clean_client.delete_webhook_integration(integration.id) + clean_project.delete_webhook_integration(integration.id) def test_webhook_intake_rejects_inactive_integration_without_stats( - clean_client, + clean_project, ): - store = _require_rest_store(clean_client) - result = clean_client.create_webhook_integration( + store = _require_rest_store(clean_project) + result = clean_project.create_webhook_integration( name=sample_name("webhook-intake-inactive"), webhook_type=WebhookType.CUSTOM, active=False, @@ -174,18 +174,18 @@ def test_webhook_intake_rejects_inactive_integration_without_stats( assert response.status_code == 409 - updated = clean_client.get_webhook_integration(integration.id) + updated = clean_project.get_webhook_integration(integration.id) assert updated.stats.received_count == 0 assert updated.stats.accepted_count == 0 assert updated.stats.auth_failed_count == 0 assert updated.stats.invalid_payload_count == 0 finally: - clean_client.delete_webhook_integration(integration.id) + clean_project.delete_webhook_integration(integration.id) -def test_webhook_intake_does_not_record_type_mismatch(clean_client): - store = _require_rest_store(clean_client) - result = clean_client.create_webhook_integration( +def test_webhook_intake_does_not_record_type_mismatch(clean_project): + store = _require_rest_store(clean_project) + result = clean_project.create_webhook_integration( name=sample_name("webhook-intake-mismatch"), webhook_type=WebhookType.CUSTOM, ) @@ -202,19 +202,19 @@ def test_webhook_intake_does_not_record_type_mismatch(clean_client): assert response.status_code == 404 - updated = clean_client.get_webhook_integration(integration.id) + updated = clean_project.get_webhook_integration(integration.id) assert updated.stats.received_count == 0 assert updated.stats.accepted_count == 0 assert updated.stats.auth_failed_count == 0 assert updated.stats.invalid_payload_count == 0 finally: - clean_client.delete_webhook_integration(integration.id) + clean_project.delete_webhook_integration(integration.id) def test_webhook_intake_returns_not_found_for_unknown_integration( - clean_client, + clean_project, ): - store = _require_rest_store(clean_client) + store = _require_rest_store(clean_project) response = _post_webhook( store=store, endpoint_path=f"/api/v1/webhooks/custom/{uuid4()}/events", From 998263513f687d096e6382de04cdaffee9333a13 Mon Sep 17 00:00:00 2001 From: Iason Andriopoulos Date: Mon, 13 Jul 2026 11:47:21 +0300 Subject: [PATCH 08/18] Enable users to pass secret ref for api key --- src/zenml/cli/webhook_integration.py | 43 ++++-- src/zenml/client.py | 11 +- .../models/v2/core/webhook_integration.py | 13 +- .../routers/webhook_integration_endpoints.py | 44 +++++- src/zenml/zen_stores/sql_zen_store.py | 129 ++++++++++++++++-- .../functional/webhooks/test_cli.py | 47 ++++++- .../functional/webhooks/test_client.py | 43 ++++++ .../webhooks/test_events_endpoint.py | 44 ++++++ .../functional/webhooks/test_zen_store.py | 37 +++++ .../test_trackio_experiment_tracker.py | 1 - .../test_webhook_integration_endpoints.py | 80 +++++++++++ .../test_webhook_integration_schemas.py | 16 +++ 12 files changed, 475 insertions(+), 33 deletions(-) diff --git a/src/zenml/cli/webhook_integration.py b/src/zenml/cli/webhook_integration.py index ef7d8c82ff4..fb39d1159ca 100644 --- a/src/zenml/cli/webhook_integration.py +++ b/src/zenml/cli/webhook_integration.py @@ -11,6 +11,7 @@ from zenml.client import Client from zenml.console import console from zenml.enums import CliCategories, WebhookType +from zenml.exceptions import IllegalOperationError from zenml.models import WebhookIntegrationFilter, WebhookIntegrationResponse @@ -29,7 +30,12 @@ def webhook_integration() -> None: type=click.Choice([value.value for value in WebhookType]), required=True, ) -@click.option("--secret", type=str, default=None) +@click.option( + "--secret", + type=str, + default=None, + help="Set a direct signing secret or ZenML secret reference.", +) @click.option("--inactive", is_flag=True, default=False) def create_webhook_integration( name: str, @@ -125,8 +131,17 @@ def list_webhook_integrations( @click.option( "--active/--inactive", "active", default=None, help="Set active state." ) +@click.option( + "--secret", + type=str, + default=None, + help="Set a direct signing secret or ZenML secret reference.", +) def update_webhook_integration( - name_or_id: str, new_name: str | None, active: bool | None + name_or_id: str, + new_name: str | None, + active: bool | None, + secret: str | None, ) -> None: """Update a webhook integration. @@ -134,9 +149,13 @@ def update_webhook_integration( name_or_id: The integration name or ID. new_name: The new integration name. active: The new active state. + secret: A direct signing secret or ZenML secret reference. """ integration = Client().update_webhook_integration( - name_id_or_prefix=name_or_id, name=new_name, active=active + name_id_or_prefix=name_or_id, + name=new_name, + active=active, + secret=secret, ) cli_utils.print_pydantic_model( title=f"Webhook integration '{integration.name}'", @@ -146,7 +165,12 @@ def update_webhook_integration( @webhook_integration.command("rotate-secret") @click.argument("name_or_id", type=str) -@click.option("--secret", type=str, default=None) +@click.option( + "--secret", + type=str, + default=None, + help="Set an optional direct replacement secret.", +) def rotate_webhook_integration_secret( name_or_id: str, secret: str | None ) -> None: @@ -154,11 +178,14 @@ def rotate_webhook_integration_secret( Args: name_or_id: The integration name or ID. - secret: An optional user-supplied replacement secret. + secret: An optional direct replacement secret. """ - result = Client().rotate_webhook_integration_secret( - name_id_or_prefix=name_or_id, secret=secret - ) + try: + result = Client().rotate_webhook_integration_secret( + name_id_or_prefix=name_or_id, secret=secret + ) + except IllegalOperationError as error: + cli_utils.exception(error) cli_utils.declare(f"Signing secret: {result.secret.get_secret_value()}") diff --git a/src/zenml/client.py b/src/zenml/client.py index 6b2035f6cc6..8724e172e83 100644 --- a/src/zenml/client.py +++ b/src/zenml/client.py @@ -6855,7 +6855,8 @@ def create_webhook_integration( name: The integration name. webhook_type: The webhook provider type. active: Whether the integration accepts events immediately. - secret: An optional user-supplied signing secret. + secret: An optional direct signing secret or ZenML secret + reference. Returns: The created integration and any generated signing secret. @@ -6956,6 +6957,7 @@ def update_webhook_integration( name_id_or_prefix: str | UUID, name: str | None = None, active: bool | None = None, + secret: str | None = None, project: str | UUID | None = None, ) -> WebhookIntegrationResponse: """Update a webhook integration. @@ -6964,6 +6966,7 @@ def update_webhook_integration( name_id_or_prefix: The integration name, ID, or ID prefix. name: The new integration name. active: The new active state. + secret: A new direct signing secret or ZenML secret reference. project: The project name or ID. Returns: @@ -6976,7 +6979,9 @@ def update_webhook_integration( ) return self.zen_store.update_webhook_integration( integration_id=integration.id, - update=WebhookIntegrationUpdate(name=name, active=active), + update=WebhookIntegrationUpdate( + name=name, active=active, secret=secret + ), ) def delete_webhook_integration( @@ -7007,7 +7012,7 @@ def rotate_webhook_integration_secret( Args: name_id_or_prefix: The integration name, ID, or ID prefix. - secret: An optional user-supplied replacement secret. + secret: An optional direct replacement secret. project: The project name or ID. Returns: diff --git a/src/zenml/models/v2/core/webhook_integration.py b/src/zenml/models/v2/core/webhook_integration.py index 6bd8aecca94..2cf8b0daf85 100644 --- a/src/zenml/models/v2/core/webhook_integration.py +++ b/src/zenml/models/v2/core/webhook_integration.py @@ -44,7 +44,8 @@ class WebhookIntegrationRequest(ProjectScopedRequest): active: bool = True secret: NonEmptyPlainSerializedSecretStr | None = Field( default=None, - description="Optional signing secret. One is generated when omitted.", + description="Optional direct signing secret or ZenML secret " + "reference. A direct secret is generated when omitted.", ) @@ -53,6 +54,10 @@ class WebhookIntegrationUpdate(BaseUpdate): name: str | None = Field(default=None, max_length=STR_FIELD_MAX_LENGTH) active: bool | None = None + secret: NonEmptyPlainSerializedSecretStr | None = Field( + default=None, + description="A direct signing secret or ZenML secret reference.", + ) class WebhookIntegrationResponseBody(ProjectScopedResponseBody): @@ -169,7 +174,11 @@ class WebhookIntegrationCreateResponse(BaseZenModel): class WebhookIntegrationSecretRequest(BaseZenModel): """Request model for rotating a webhook integration secret.""" - secret: NonEmptyPlainSerializedSecretStr | None = None + secret: NonEmptyPlainSerializedSecretStr | None = Field( + default=None, + description="Optional direct replacement secret. Secret references " + "must be configured through an integration update.", + ) class WebhookIntegrationSecretResponse(BaseZenModel): diff --git a/src/zenml/zen_server/routers/webhook_integration_endpoints.py b/src/zenml/zen_server/routers/webhook_integration_endpoints.py index 05f92db53f5..ad278e49d59 100644 --- a/src/zenml/zen_server/routers/webhook_integration_endpoints.py +++ b/src/zenml/zen_server/routers/webhook_integration_endpoints.py @@ -12,6 +12,7 @@ status, ) from fastapi.responses import Response +from pydantic import SecretStr from starlette.concurrency import run_in_threadpool from starlette.datastructures import Headers @@ -33,6 +34,10 @@ WebhookIntegrationSecretResponse, WebhookIntegrationUpdate, ) +from zenml.utils.secret_utils import ( + is_secret_reference, + parse_secret_reference, +) from zenml.webhooks import ( WebhookAuthenticationError, WebhookPayloadError, @@ -44,10 +49,12 @@ verify_permissions_and_delete_entity, verify_permissions_and_get_entity, verify_permissions_and_list_entities, - verify_permissions_and_update_entity, ) from zenml.zen_server.rbac.models import Action, ResourceType -from zenml.zen_server.rbac.utils import verify_permission_for_model +from zenml.zen_server.rbac.utils import ( + dehydrate_response_model, + verify_permission_for_model, +) from zenml.zen_server.utils import ( async_fastapi_endpoint_wrapper, async_handle_endpoint_errors, @@ -67,6 +74,24 @@ ) +def _verify_webhook_secret_reference_access( + secret: SecretStr | None, +) -> None: + """Verify access to a referenced ZenML secret value. + + Args: + secret: A direct signing secret or ZenML secret reference. + """ + if secret is None or not is_secret_reference(secret): + return + + reference = parse_secret_reference(secret) + referenced_secret = zen_store().get_secret_by_name_or_id(reference.name) + verify_permission_for_model( + model=referenced_secret, action=Action.READ_SECRET_VALUE + ) + + @management_router.post("") @async_fastapi_endpoint_wrapper def create_webhook_integration( @@ -82,6 +107,7 @@ def create_webhook_integration( The created integration and any generated signing secret. """ verify_permission_for_model(model=integration, action=Action.CREATE) + _verify_webhook_secret_reference_access(integration.secret) return zen_store().create_webhook_integration(integration) @@ -150,12 +176,16 @@ def update_webhook_integration( Returns: The updated webhook integration. """ - return verify_permissions_and_update_entity( - id=integration_id, - update_model=update, - get_method=zen_store().get_webhook_integration, - update_method=zen_store().update_webhook_integration, + integration = zen_store().get_webhook_integration( + integration_id, hydrate=False + ) + verify_permission_for_model(model=integration, action=Action.UPDATE) + _verify_webhook_secret_reference_access(update.secret) + updated_integration = zen_store().update_webhook_integration( + integration_id=integration.id, + update=update, ) + return dehydrate_response_model(updated_integration) @management_router.delete("/{integration_id}") diff --git a/src/zenml/zen_stores/sql_zen_store.py b/src/zenml/zen_stores/sql_zen_store.py index 49c9aadebc4..afabd298c91 100644 --- a/src/zenml/zen_stores/sql_zen_store.py +++ b/src/zenml/zen_stores/sql_zen_store.py @@ -394,7 +394,11 @@ from zenml.utils.networking_utils import ( replace_localhost_with_internal_hostname, ) -from zenml.utils.secret_utils import PlainSerializedSecretStr +from zenml.utils.secret_utils import ( + PlainSerializedSecretStr, + is_secret_reference, + parse_secret_reference, +) from zenml.utils.string_utils import ( format_name_template, random_str, @@ -508,6 +512,11 @@ logger = get_logger(__name__) +_WEBHOOK_SECRET_VALUE_KEY = "secret" +_WEBHOOK_SECRET_REFERENCE_KEY = "secret_reference" +_WEBHOOK_SECRET_REFERENCE_ID_KEY = "secret_reference_id" +_WEBHOOK_SECRET_REFERENCE_VALUE_KEY = "secret_reference_key" + ZENML_SQLITE_DB_FILENAME = "zenml.db" @@ -8135,17 +8144,58 @@ def _create_webhook_integration_secret( Returns: The internal secret ID. """ + values = self._get_webhook_integration_secret_storage_values( + secret=secret, session=session + ) return self._create_secret_schema( SecretRequest( user=self._get_active_user(session=session).id, name=f"webhook-{uuid.uuid4().hex}", private=False, - values={"secret": PlainSerializedSecretStr(secret)}, + values=values, ), session=session, internal=True, ).id + def _get_webhook_integration_secret_storage_values( + self, secret: str, session: Session + ) -> Dict[str, Optional[str]]: + """Build internal storage values for a webhook signing credential. + + Args: + secret: A direct signing secret or ZenML secret reference. + session: The active database session. + + Returns: + Values to persist in the internal webhook secret. + + Raises: + KeyError: If the referenced secret or key does not exist. + ValueError: If the referenced value is empty. + """ + if not is_secret_reference(secret): + return {_WEBHOOK_SECRET_VALUE_KEY: secret} + + reference = parse_secret_reference(secret) + referenced_secret = self._get_visible_secret_schema( + reference.name, session=session + ) + referenced_values = self._get_secret_values(referenced_secret.id) + if reference.key not in referenced_values: + raise KeyError( + f"Secret {reference.name} does not contain key " + f"{reference.key}." + ) + if not referenced_values[reference.key].strip(): + raise ValueError("Webhook signing secret must not be empty.") + + return { + _WEBHOOK_SECRET_REFERENCE_KEY: secret, + _WEBHOOK_SECRET_REFERENCE_ID_KEY: str(referenced_secret.id), + _WEBHOOK_SECRET_REFERENCE_VALUE_KEY: reference.key, + } + def create_webhook_integration( self, integration: WebhookIntegrationRequest ) -> WebhookIntegrationCreateResponse: @@ -8265,6 +8315,15 @@ def update_webhook_integration( self._verify_name_uniqueness( resource=update, schema=schema, session=session ) + if update.secret is not None: + values = self._get_webhook_integration_secret_storage_values( + secret=update.secret.get_secret_value(), session=session + ) + self._update_secret_values( + secret_id=schema.secret_id, + values=values, + overwrite=True, + ) schema.update(update) session.add(schema) session.commit() @@ -8299,21 +8358,48 @@ def rotate_webhook_integration_secret( Returns: The newly active signing secret. + + Raises: + IllegalOperationError: If the integration uses a secret reference + or the replacement secret is a secret reference. """ - secret = ( - request.secret.get_secret_value() - if request.secret is not None - else secrets.token_urlsafe(32) - ) with Session(self.engine) as session: schema = self._get_schema_by_id( resource_id=integration_id, schema_class=WebhookIntegrationSchema, session=session, ) + current_values = self._get_secret_values(schema.secret_id) + if _WEBHOOK_SECRET_REFERENCE_KEY in current_values: + reference = current_values[_WEBHOOK_SECRET_REFERENCE_KEY] + parsed_reference = parse_secret_reference(reference) + raise IllegalOperationError( + "Cannot rotate this webhook integration signing secret " + f"because it uses the secret reference `{reference}`. " + "Update the referenced value instead with " + f"`zenml secret update {parsed_reference.name} " + f"--{parsed_reference.key}=`, or update the " + "webhook integration to use a managed secret." + ) + + if request.secret is not None and is_secret_reference( + request.secret + ): + raise IllegalOperationError( + "Secret references cannot be configured through secret " + "rotation. Use `zenml webhook-integration update " + f"{schema.name} --secret='" + f"{request.secret.get_secret_value()}'` instead." + ) + + secret = ( + request.secret.get_secret_value() + if request.secret is not None + else secrets.token_urlsafe(32) + ) self._update_secret_values( secret_id=schema.secret_id, - values={"secret": secret}, + values={_WEBHOOK_SECRET_VALUE_KEY: secret}, overwrite=True, ) schema.updated = utc_now() @@ -8331,6 +8417,10 @@ def get_webhook_integration_secret(self, integration_id: UUID) -> str: Returns: The signing secret. + + Raises: + RuntimeError: If the referenced secret value cannot be resolved or + is empty. """ with Session(self.engine) as session: schema = self._get_schema_by_id( @@ -8338,7 +8428,28 @@ def get_webhook_integration_secret(self, integration_id: UUID) -> str: schema_class=WebhookIntegrationSchema, session=session, ) - return self._get_secret_values(schema.secret_id)["secret"] + values = self._get_secret_values(schema.secret_id) + + if _WEBHOOK_SECRET_VALUE_KEY in values: + return values[_WEBHOOK_SECRET_VALUE_KEY] + + try: + reference_id = UUID(values[_WEBHOOK_SECRET_REFERENCE_ID_KEY]) + reference_key = values[_WEBHOOK_SECRET_REFERENCE_VALUE_KEY] + referenced_values = self._get_secret_values(reference_id) + secret = referenced_values[reference_key] + except (KeyError, ValueError) as error: + raise RuntimeError( + "The webhook integration signing secret reference can no " + "longer be resolved." + ) from error + + if not secret.strip(): + raise RuntimeError( + "The webhook integration signing secret reference resolves " + "to an empty value." + ) + return secret def record_webhook_event( self, integration_id: UUID, update: WebhookEventStatsUpdate diff --git a/tests/integration/functional/webhooks/test_cli.py b/tests/integration/functional/webhooks/test_cli.py index fd5dd135523..a01d9e519d5 100644 --- a/tests/integration/functional/webhooks/test_cli.py +++ b/tests/integration/functional/webhooks/test_cli.py @@ -46,9 +46,7 @@ def test_webhook_integration_cli_lifecycle(clean_client): assert integration.active is True list_output_buffer = io.StringIO() - with patch.object( - zenml_cli, "_original_stdout", list_output_buffer - ): + with patch.object(zenml_cli, "_original_stdout", list_output_buffer): result = runner.invoke(list_command) list_output = list_output_buffer.getvalue() + result.output @@ -117,3 +115,46 @@ def test_webhook_integration_cli_does_not_echo_user_supplied_secret( assert "secret" not in integration.model_dump() finally: _delete_if_exists(name) + + +def test_webhook_integration_cli_rejects_rotation_for_secret_reference( + clean_client, +): + """The CLI guides referenced credentials to the secret update command.""" + runner = cli_runner() + integration_name = sample_name("webhook-cli-reference") + secret_name = sample_name("webhook-cli-secret") + clean_client.create_secret(secret_name, values={"key": "secret-value"}) + + try: + create_result = runner.invoke( + create_command, + [ + integration_name, + "--type", + WebhookType.CUSTOM.value, + "--secret", + "managed-secret", + ], + ) + assert create_result.exit_code == 0, create_result.output + + update_result = runner.invoke( + update_command, + [ + integration_name, + "--secret", + f"{{{{{secret_name}.key}}}}", + ], + ) + assert update_result.exit_code == 0, update_result.output + + rotate_result = runner.invoke( + rotate_secret_command, [integration_name] + ) + + assert rotate_result.exit_code != 0 + assert "zenml secret update" in rotate_result.output + finally: + _delete_if_exists(integration_name) + clean_client.delete_secret(secret_name) diff --git a/tests/integration/functional/webhooks/test_client.py b/tests/integration/functional/webhooks/test_client.py index abfcfcf6aa4..91861718c33 100644 --- a/tests/integration/functional/webhooks/test_client.py +++ b/tests/integration/functional/webhooks/test_client.py @@ -2,6 +2,7 @@ from tests.integration.functional.utils import sample_name from zenml.enums import WebhookType +from zenml.exceptions import IllegalOperationError def test_client_webhook_integration_lifecycle(clean_client): @@ -90,3 +91,45 @@ def test_client_does_not_echo_user_supplied_webhook_secret(clean_client): assert integration.webhook_type == WebhookType.GITHUB finally: clean_client.delete_webhook_integration(result.integration.id) + + +def test_client_rejects_rotation_for_referenced_webhook_secret(clean_client): + """Referenced webhook secrets can be updated but not rotated.""" + integration_name = sample_name("webhook-client-reference") + secret_name = sample_name("webhook-signing-secret") + clean_client.create_secret(secret_name, values={"key": "initial-secret"}) + result = clean_client.create_webhook_integration( + name=integration_name, + webhook_type=WebhookType.CUSTOM, + secret="managed-secret", + ) + + try: + clean_client.update_webhook_integration( + integration_name, + secret=f"{{{{{secret_name}.key}}}}", + ) + + with pytest.raises(IllegalOperationError, match="zenml secret update"): + clean_client.rotate_webhook_integration_secret(integration_name) + + clean_client.update_webhook_integration( + integration_name, + secret="managed-secret-again", + ) + with pytest.raises( + IllegalOperationError, match="webhook-integration update" + ): + clean_client.rotate_webhook_integration_secret( + integration_name, + secret=f"{{{{{secret_name}.key}}}}", + ) + + rotated = clean_client.rotate_webhook_integration_secret( + integration_name + ) + + assert rotated.secret.get_secret_value() + finally: + clean_client.delete_webhook_integration(result.integration.id) + clean_client.delete_secret(secret_name) diff --git a/tests/integration/functional/webhooks/test_events_endpoint.py b/tests/integration/functional/webhooks/test_events_endpoint.py index 9b99fc43776..74c7c334dc3 100644 --- a/tests/integration/functional/webhooks/test_events_endpoint.py +++ b/tests/integration/functional/webhooks/test_events_endpoint.py @@ -74,6 +74,50 @@ def test_webhook_intake_accepts_valid_custom_delivery(clean_project): clean_project.delete_webhook_integration(integration.id) +def test_webhook_intake_resolves_updated_secret_reference(clean_project): + """Webhook intake resolves the current referenced secret value.""" + store = _require_rest_store(clean_project) + secret_name = sample_name("webhook-intake-reference") + clean_project.create_secret(secret_name, values={"key": "initial-secret"}) + result = clean_project.create_webhook_integration( + name=sample_name("webhook-intake-reference-integration"), + webhook_type=WebhookType.CUSTOM, + secret=f"{{{{{secret_name}.key}}}}", + ) + integration = result.integration + body = b'{"pipeline":"training"}' + + try: + response = _post_webhook( + store=store, + endpoint_path=integration.endpoint_path, + body=body, + headers={ + "X-ZenML-Event": "pipeline.ready", + "X-ZenML-Signature-256": _signature("initial-secret", body), + }, + ) + assert response.status_code == 202 + + clean_project.update_secret( + secret_name, add_or_update_values={"key": "updated-secret"} + ) + response = _post_webhook( + store=store, + endpoint_path=integration.endpoint_path, + body=body, + headers={ + "X-ZenML-Event": "pipeline.ready", + "X-ZenML-Signature-256": _signature("updated-secret", body), + }, + ) + + assert response.status_code == 202 + finally: + clean_project.delete_webhook_integration(integration.id) + clean_project.delete_secret(secret_name) + + def test_webhook_intake_records_auth_failures_for_active_integrations( clean_project, ): diff --git a/tests/integration/functional/webhooks/test_zen_store.py b/tests/integration/functional/webhooks/test_zen_store.py index 168f38f2578..e018a268f5e 100644 --- a/tests/integration/functional/webhooks/test_zen_store.py +++ b/tests/integration/functional/webhooks/test_zen_store.py @@ -8,6 +8,7 @@ WebhookIntegrationSecretRequest, WebhookIntegrationUpdate, ) +from zenml.zen_stores.sql_zen_store import SqlZenStore def test_zen_store_webhook_integration_lifecycle(clean_client): @@ -101,3 +102,39 @@ def test_zen_store_does_not_echo_user_supplied_webhook_secret(clean_client): assert integration.webhook_type == WebhookType.GITHUB finally: store.delete_webhook_integration(result.integration.id) + + +def test_sql_store_resolves_webhook_secret_references_lazily(clean_client): + """Webhook secret references resolve their current value at intake time.""" + store = clean_client.zen_store + if not isinstance(store, SqlZenStore): + pytest.skip("Webhook secret resolution is server-local behavior.") + + secret_name = sample_name("webhook-reference") + clean_client.create_secret(secret_name, values={"key": "initial-secret"}) + result = store.create_webhook_integration( + WebhookIntegrationRequest( + project=clean_client.active_project.id, + name=sample_name("webhook-store-reference"), + webhook_type=WebhookType.CUSTOM, + secret=f"{{{{{secret_name}.key}}}}", + ) + ) + + try: + assert ( + store.get_webhook_integration_secret(result.integration.id) + == "initial-secret" + ) + + clean_client.update_secret( + secret_name, add_or_update_values={"key": "rotated-secret"} + ) + + assert ( + store.get_webhook_integration_secret(result.integration.id) + == "rotated-secret" + ) + finally: + store.delete_webhook_integration(result.integration.id) + clean_client.delete_secret(secret_name) diff --git a/tests/integration/integrations/trackio/experiment_trackers/test_trackio_experiment_tracker.py b/tests/integration/integrations/trackio/experiment_trackers/test_trackio_experiment_tracker.py index 7db7d5d9d7a..80b71837c8a 100644 --- a/tests/integration/integrations/trackio/experiment_trackers/test_trackio_experiment_tracker.py +++ b/tests/integration/integrations/trackio/experiment_trackers/test_trackio_experiment_tracker.py @@ -323,7 +323,6 @@ def test_trackio_cleanup_calls_finish_when_sync_fails( def test_trackio_metadata() -> None: - tracker = _tracker(space_id="AINovice2005/zenml-F5J4jOGLJ44") tracker.get_settings = MagicMock(return_value=_settings()) diff --git a/tests/unit/zen_server/endpoints/test_webhook_integration_endpoints.py b/tests/unit/zen_server/endpoints/test_webhook_integration_endpoints.py index 59bd99804e4..58096225036 100644 --- a/tests/unit/zen_server/endpoints/test_webhook_integration_endpoints.py +++ b/tests/unit/zen_server/endpoints/test_webhook_integration_endpoints.py @@ -3,8 +3,10 @@ import pytest from fastapi import HTTPException, status +from pydantic import SecretStr from zenml.enums import WebhookType +from zenml.models import WebhookIntegrationUpdate from zenml.webhooks import WebhookAuthenticationError, WebhookPayloadError from zenml.zen_server.routers import webhook_integration_endpoints as endpoints @@ -64,6 +66,84 @@ def _receive(integration_id): ) +def test_verify_webhook_secret_reference_access_checks_value_permission( + monkeypatch, +) -> None: + """Referenced credentials require permission to read the secret value.""" + referenced_secret = SimpleNamespace(name="webhook-secret") + store = SimpleNamespace( + get_secret_by_name_or_id=lambda name: referenced_secret + ) + verified = [] + monkeypatch.setattr(endpoints, "zen_store", lambda: store) + monkeypatch.setattr( + endpoints, + "verify_permission_for_model", + lambda **kwargs: verified.append(kwargs), + ) + + endpoints._verify_webhook_secret_reference_access( + SecretStr("{{webhook-secret.key}}") + ) + + assert verified == [ + { + "model": referenced_secret, + "action": endpoints.Action.READ_SECRET_VALUE, + } + ] + + +def test_update_checks_integration_permission_before_secret_access( + monkeypatch, +) -> None: + """Integration update access is checked before referenced secret access.""" + integration_id = uuid4() + integration = SimpleNamespace(id=integration_id) + updated_integration = SimpleNamespace(id=integration_id) + calls = [] + + class _UpdateStore: + def get_webhook_integration(self, integration_id, hydrate): + calls.append("get_integration") + return integration + + def update_webhook_integration(self, integration_id, update): + calls.append("update_integration") + return updated_integration + + monkeypatch.setattr(endpoints, "zen_store", _UpdateStore) + monkeypatch.setattr( + endpoints, + "verify_permission_for_model", + lambda **kwargs: calls.append(f"verify_{kwargs['action'].value}"), + ) + monkeypatch.setattr( + endpoints, + "_verify_webhook_secret_reference_access", + lambda secret: calls.append("verify_secret_access"), + ) + monkeypatch.setattr( + endpoints, + "dehydrate_response_model", + lambda model: model, + ) + + result = endpoints.update_webhook_integration.__wrapped__( + integration_id=integration_id, + update=WebhookIntegrationUpdate(secret="{{webhook.key}}"), + _=SimpleNamespace(), + ) + + assert result is updated_integration + assert calls == [ + "get_integration", + "verify_update", + "verify_secret_access", + "update_integration", + ] + + def test_receive_webhook_event_returns_404_for_missing_integration( monkeypatch, ) -> None: diff --git a/tests/unit/zen_stores/schemas/test_webhook_integration_schemas.py b/tests/unit/zen_stores/schemas/test_webhook_integration_schemas.py index 7f6382c6f79..3f5c4f05024 100644 --- a/tests/unit/zen_stores/schemas/test_webhook_integration_schemas.py +++ b/tests/unit/zen_stores/schemas/test_webhook_integration_schemas.py @@ -13,6 +13,7 @@ WebhookIntegrationRequest, WebhookIntegrationSecretRequest, WebhookIntegrationStats, + WebhookIntegrationUpdate, ) from zenml.zen_stores.schemas.webhook_integration_schemas import ( WebhookIntegrationSchema, @@ -99,6 +100,13 @@ def test_webhook_integration_secret_request_rejects_empty_secret( WebhookIntegrationSecretRequest(secret=secret) +@pytest.mark.parametrize("secret", ["", " "]) +def test_webhook_integration_update_rejects_empty_secret(secret: str) -> None: + """Webhook integration updates reject empty signing secrets.""" + with pytest.raises(ValidationError): + WebhookIntegrationUpdate(secret=secret) + + def test_webhook_integration_requests_allow_missing_secret() -> None: """Webhook integration requests allow missing secrets for generation.""" integration_request = WebhookIntegrationRequest( @@ -112,6 +120,14 @@ def test_webhook_integration_requests_allow_missing_secret() -> None: assert secret_request.secret is None +def test_webhook_integration_update_accepts_secret_reference() -> None: + """Webhook integration updates accept a secret reference.""" + update = WebhookIntegrationUpdate(secret="{{webhook.secret}}") + + assert update.secret is not None + assert update.secret.get_secret_value() == "{{webhook.secret}}" + + def test_webhook_integration_schema_to_model_includes_body_and_metadata() -> ( None ): From cf94d4f555aa9c8e042dfe25c0dc176d16133bec Mon Sep 17 00:00:00 2001 From: Iason Andriopoulos Date: Mon, 13 Jul 2026 12:28:05 +0300 Subject: [PATCH 09/18] Generalize webhook adapters --- src/zenml/webhooks/adapters.py | 86 ++++++++++++++++--- .../routers/webhook_integration_endpoints.py | 2 +- tests/unit/webhooks/test_adapters.py | 60 ++++++++++++- .../test_webhook_integration_endpoints.py | 1 + 4 files changed, 132 insertions(+), 17 deletions(-) diff --git a/src/zenml/webhooks/adapters.py b/src/zenml/webhooks/adapters.py index 8ccf8518f7b..d176aab03cb 100644 --- a/src/zenml/webhooks/adapters.py +++ b/src/zenml/webhooks/adapters.py @@ -14,7 +14,7 @@ class WebhookAuthenticationError(ValueError): - """Raised when a webhook signature cannot be authenticated.""" + """Raised when a webhook request cannot be authenticated.""" class WebhookPayloadError(ValueError): @@ -34,9 +34,6 @@ class BaseWebhookAdapter(ABC): """Base class for provider-specific webhook adapters.""" webhook_type: WebhookType - signature_header: str - event_header: str - delivery_header: str def validate( self, body: bytes, headers: Mapping[str, str], secret: str @@ -46,13 +43,13 @@ def validate( Args: body: The exact raw request body. headers: The request headers. - secret: The integration signing secret. + secret: The integration authentication secret. Returns: The authenticated webhook event. Raises: - WebhookAuthenticationError: If the signature is invalid. + WebhookAuthenticationError: If authentication fails. WebhookPayloadError: If the event metadata or payload is invalid. """ # noqa: DOC502 self.authenticate(body=body, headers=headers, secret=secret) @@ -71,11 +68,6 @@ def parse(self, body: bytes, headers: Mapping[str, str]) -> WebhookEvent: Raises: WebhookPayloadError: If the event metadata or payload is invalid. """ - event_type = headers.get(self.event_header) - if not event_type: - raise WebhookPayloadError( - f"Missing required {self.event_header} header." - ) try: payload = json.loads(body) except (UnicodeDecodeError, json.JSONDecodeError) as error: @@ -86,13 +78,45 @@ def parse(self, body: bytes, headers: Mapping[str, str]) -> WebhookEvent: raise WebhookPayloadError( "Request body must contain a top-level JSON object." ) + event_type = self.get_event_type(payload=payload, headers=headers) return WebhookEvent( webhook_type=self.webhook_type, event_type=event_type, - delivery_id=headers.get(self.delivery_header), + delivery_id=self.get_delivery_id(payload=payload, headers=headers), payload=payload, ) + @abstractmethod + def get_event_type( + self, payload: dict[str, Any], headers: Mapping[str, str] + ) -> str: + """Extract the provider event type. + + Args: + payload: The parsed JSON object. + headers: The request headers. + + Returns: + The provider event type. + + Raises: + WebhookPayloadError: If the event type is missing or invalid. + """ + + def get_delivery_id( + self, payload: dict[str, Any], headers: Mapping[str, str] + ) -> str | None: + """Extract the optional provider delivery ID. + + Args: + payload: The parsed JSON object. + headers: The request headers. + + Returns: + The provider delivery ID, if available. + """ + return None + @abstractmethod def authenticate( self, body: bytes, headers: Mapping[str, str], secret: str @@ -102,13 +126,15 @@ def authenticate( Args: body: The exact raw request body. headers: The request headers. - secret: The integration signing secret. + secret: The integration authentication secret. """ class HMACSHA256WebhookAdapter(BaseWebhookAdapter): """Webhook adapter using a sha256-prefixed HMAC signature.""" + signature_header: str + def authenticate( self, body: bytes, headers: Mapping[str, str], secret: str ) -> None: @@ -144,6 +170,23 @@ class GitHubWebhookAdapter(HMACSHA256WebhookAdapter): event_header = "x-github-event" delivery_header = "x-github-delivery" + def get_event_type( + self, payload: dict[str, Any], headers: Mapping[str, str] + ) -> str: + """Extract the GitHub event type from its request header.""" + event_type = headers.get(self.event_header) + if not event_type: + raise WebhookPayloadError( + f"Missing required {self.event_header} header." + ) + return event_type + + def get_delivery_id( + self, payload: dict[str, Any], headers: Mapping[str, str] + ) -> str | None: + """Extract the optional GitHub delivery ID.""" + return headers.get(self.delivery_header) + class CustomWebhookAdapter(HMACSHA256WebhookAdapter): """ZenML custom webhook adapter.""" @@ -153,6 +196,23 @@ class CustomWebhookAdapter(HMACSHA256WebhookAdapter): event_header = "x-zenml-event" delivery_header = "x-zenml-delivery" + def get_event_type( + self, payload: dict[str, Any], headers: Mapping[str, str] + ) -> str: + """Extract the custom event type from its request header.""" + event_type = headers.get(self.event_header) + if not event_type: + raise WebhookPayloadError( + f"Missing required {self.event_header} header." + ) + return event_type + + def get_delivery_id( + self, payload: dict[str, Any], headers: Mapping[str, str] + ) -> str | None: + """Extract the optional custom delivery ID.""" + return headers.get(self.delivery_header) + _ADAPTERS: dict[WebhookType, BaseWebhookAdapter] = { WebhookType.GITHUB: GitHubWebhookAdapter(), diff --git a/src/zenml/zen_server/routers/webhook_integration_endpoints.py b/src/zenml/zen_server/routers/webhook_integration_endpoints.py index ad278e49d59..6f4c91f5e30 100644 --- a/src/zenml/zen_server/routers/webhook_integration_endpoints.py +++ b/src/zenml/zen_server/routers/webhook_integration_endpoints.py @@ -303,7 +303,7 @@ def _receive_webhook_event( ) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid webhook signature.", + detail="Invalid webhook authentication.", ) from error if not integration.active: diff --git a/tests/unit/webhooks/test_adapters.py b/tests/unit/webhooks/test_adapters.py index a2598a3abc7..8b97e52306f 100644 --- a/tests/unit/webhooks/test_adapters.py +++ b/tests/unit/webhooks/test_adapters.py @@ -6,6 +6,7 @@ from zenml.enums import WebhookType from zenml.webhooks.adapters import ( + BaseWebhookAdapter, CustomWebhookAdapter, GitHubWebhookAdapter, WebhookAuthenticationError, @@ -14,6 +15,30 @@ ) +class _BodyMetadataBearerAdapter(BaseWebhookAdapter): + """Test adapter with bearer auth and body-derived event metadata.""" + + webhook_type = WebhookType.CUSTOM + + def authenticate( + self, body: bytes, headers: Mapping[str, str], secret: str + ) -> None: + if headers.get("authorization") != f"Bearer {secret}": + raise WebhookAuthenticationError("Invalid bearer token.") + + def get_event_type(self, payload: dict, headers: Mapping[str, str]) -> str: + event_type = payload.get("type") + if not isinstance(event_type, str) or not event_type: + raise WebhookPayloadError("Missing event type in request body.") + return event_type + + def get_delivery_id( + self, payload: dict, headers: Mapping[str, str] + ) -> str | None: + delivery_id = payload.get("webhookId") + return delivery_id if isinstance(delivery_id, str) else None + + def _signature(secret: str, body: bytes) -> str: return ( "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() @@ -37,7 +62,7 @@ def test_get_webhook_adapter_returns_registered_adapter( @pytest.mark.parametrize( - "adapter, headers", + "adapter, headers, expected_event_type, expected_delivery_id", [ ( GitHubWebhookAdapter(), @@ -45,6 +70,8 @@ def test_get_webhook_adapter_returns_registered_adapter( "x-github-event": "push", "x-github-delivery": "github-delivery-id", }, + "push", + "github-delivery-id", ), ( CustomWebhookAdapter(), @@ -52,12 +79,16 @@ def test_get_webhook_adapter_returns_registered_adapter( "x-zenml-event": "pipeline.ready", "x-zenml-delivery": "custom-delivery-id", }, + "pipeline.ready", + "custom-delivery-id", ), ], ) def test_validate_returns_provider_event_for_signed_request( adapter: GitHubWebhookAdapter | CustomWebhookAdapter, headers: dict[str, str], + expected_event_type: str, + expected_delivery_id: str, ) -> None: secret = "webhook-secret" body = b'{"repository":"zenml","run_id":42}' @@ -66,11 +97,34 @@ def test_validate_returns_provider_event_for_signed_request( event = adapter.validate(body=body, headers=headers, secret=secret) assert event.webhook_type == adapter.webhook_type - assert event.event_type == headers[adapter.event_header] - assert event.delivery_id == headers[adapter.delivery_header] + assert event.event_type == expected_event_type + assert event.delivery_id == expected_delivery_id assert event.payload == {"repository": "zenml", "run_id": 42} +def test_validate_supports_bearer_auth_and_body_event_metadata() -> None: + adapter = _BodyMetadataBearerAdapter() + body = b'{"type":"monitor.page","webhookId":"delivery-id"}' + + event = adapter.validate( + body=body, + headers={"authorization": "Bearer webhook-secret"}, + secret="webhook-secret", + ) + + assert event.event_type == "monitor.page" + assert event.delivery_id == "delivery-id" + + +def test_parse_rejects_missing_body_event_type() -> None: + adapter = _BodyMetadataBearerAdapter() + + with pytest.raises( + WebhookPayloadError, match="Missing event type in request body" + ): + adapter.parse(body=b'{"webhookId":"delivery-id"}', headers={}) + + def test_authentication_uses_exact_raw_body_bytes() -> None: adapter = CustomWebhookAdapter() secret = "webhook-secret" diff --git a/tests/unit/zen_server/endpoints/test_webhook_integration_endpoints.py b/tests/unit/zen_server/endpoints/test_webhook_integration_endpoints.py index 58096225036..0d34d6dd31e 100644 --- a/tests/unit/zen_server/endpoints/test_webhook_integration_endpoints.py +++ b/tests/unit/zen_server/endpoints/test_webhook_integration_endpoints.py @@ -200,6 +200,7 @@ def test_receive_webhook_event_records_auth_failure_for_active_integration( _receive(integration_id) assert error.value.status_code == status.HTTP_401_UNAUTHORIZED + assert error.value.detail == "Invalid webhook authentication." assert store.secret_requests == 1 assert len(store.records) == 1 recorded_id, update = store.records[0] From 1e94a5d1c17ee33a9f74344f74e39e51bc9e9361 Mon Sep 17 00:00:00 2001 From: Iason Andriopoulos Date: Tue, 14 Jul 2026 11:21:33 +0300 Subject: [PATCH 10/18] Compression session --- src/zenml/webhooks/adapters.py | 46 +++- .../webhooks/test_events_endpoint.py | 259 ++++++++---------- .../functional/webhooks/test_zen_store.py | 25 -- .../test_webhook_integration_endpoints.py | 241 +++++++--------- .../test_webhook_integration_schemas.py | 66 ++--- 5 files changed, 263 insertions(+), 374 deletions(-) diff --git a/src/zenml/webhooks/adapters.py b/src/zenml/webhooks/adapters.py index d176aab03cb..e4d0d7f7a26 100644 --- a/src/zenml/webhooks/adapters.py +++ b/src/zenml/webhooks/adapters.py @@ -173,7 +173,18 @@ class GitHubWebhookAdapter(HMACSHA256WebhookAdapter): def get_event_type( self, payload: dict[str, Any], headers: Mapping[str, str] ) -> str: - """Extract the GitHub event type from its request header.""" + """Extract the GitHub event type from its request header. + + Args: + payload: The parsed JSON object. + headers: The request headers. + + Returns: + The GitHub event type. + + Raises: + WebhookPayloadError: If the event type header is missing. + """ event_type = headers.get(self.event_header) if not event_type: raise WebhookPayloadError( @@ -184,7 +195,15 @@ def get_event_type( def get_delivery_id( self, payload: dict[str, Any], headers: Mapping[str, str] ) -> str | None: - """Extract the optional GitHub delivery ID.""" + """Extract the optional GitHub delivery ID. + + Args: + payload: The parsed JSON object. + headers: The request headers. + + Returns: + The GitHub delivery ID, if available. + """ return headers.get(self.delivery_header) @@ -199,7 +218,18 @@ class CustomWebhookAdapter(HMACSHA256WebhookAdapter): def get_event_type( self, payload: dict[str, Any], headers: Mapping[str, str] ) -> str: - """Extract the custom event type from its request header.""" + """Extract the custom event type from its request header. + + Args: + payload: The parsed JSON object. + headers: The request headers. + + Returns: + The custom event type. + + Raises: + WebhookPayloadError: If the event type header is missing. + """ event_type = headers.get(self.event_header) if not event_type: raise WebhookPayloadError( @@ -210,7 +240,15 @@ def get_event_type( def get_delivery_id( self, payload: dict[str, Any], headers: Mapping[str, str] ) -> str | None: - """Extract the optional custom delivery ID.""" + """Extract the optional custom delivery ID. + + Args: + payload: The parsed JSON object. + headers: The request headers. + + Returns: + The custom delivery ID, if available. + """ return headers.get(self.delivery_header) diff --git a/tests/integration/functional/webhooks/test_events_endpoint.py b/tests/integration/functional/webhooks/test_events_endpoint.py index 74c7c334dc3..8a7b011fbb9 100644 --- a/tests/integration/functional/webhooks/test_events_endpoint.py +++ b/tests/integration/functional/webhooks/test_events_endpoint.py @@ -7,6 +7,7 @@ from tests.integration.functional.utils import sample_name from zenml.enums import WebhookType +from zenml.models import WebhookIntegrationCreateResponse from zenml.zen_stores.rest_zen_store import RestZenStore @@ -37,44 +38,67 @@ def _post_webhook( ) -def test_webhook_intake_accepts_valid_custom_delivery(clean_project): +@pytest.fixture +def webhook_integration_factory(clean_project): + integration_ids = [] + + def create( + name_prefix: str, + *, + active: bool = True, + secret: str | None = None, + ) -> WebhookIntegrationCreateResponse: + result = clean_project.create_webhook_integration( + name=sample_name(name_prefix), + webhook_type=WebhookType.CUSTOM, + active=active, + secret=secret, + ) + integration_ids.append(result.integration.id) + return result + + yield create + + for integration_id in integration_ids: + clean_project.delete_webhook_integration(integration_id) + + +def test_webhook_intake_accepts_valid_custom_delivery( + clean_project, webhook_integration_factory +): store = _require_rest_store(clean_project) - result = clean_project.create_webhook_integration( - name=sample_name("webhook-intake-valid"), - webhook_type=WebhookType.CUSTOM, - ) + result = webhook_integration_factory("webhook-intake-valid") integration = result.integration assert result.secret is not None secret = result.secret.get_secret_value() body = b'{"pipeline":"training"}' - try: - response = _post_webhook( - store=store, - endpoint_path=integration.endpoint_path, - body=body, - headers={ - "X-ZenML-Event": "pipeline.ready", - "X-ZenML-Delivery": "delivery-1", - "X-ZenML-Signature-256": _signature(secret, body), - }, - ) + response = _post_webhook( + store=store, + endpoint_path=integration.endpoint_path, + body=body, + headers={ + "X-ZenML-Event": "pipeline.ready", + "X-ZenML-Delivery": "delivery-1", + "X-ZenML-Signature-256": _signature(secret, body), + }, + ) - assert response.status_code == 202 - assert response.content == b"" + assert response.status_code == 202 + assert response.content == b"" - updated = clean_project.get_webhook_integration(integration.id) - assert updated.stats.received_count == 1 - assert updated.stats.accepted_count == 1 - assert updated.stats.auth_failed_count == 0 - assert updated.stats.invalid_payload_count == 0 - assert updated.stats.last_received_at is not None - assert updated.stats.last_accepted_at is not None - finally: - clean_project.delete_webhook_integration(integration.id) + updated = clean_project.get_webhook_integration(integration.id) + assert updated.stats.received_count == 1 + assert updated.stats.accepted_count == 1 + assert updated.stats.auth_failed_count == 0 + assert updated.stats.invalid_payload_count == 0 + assert updated.stats.last_received_at is not None + assert updated.stats.last_accepted_at is not None -def test_webhook_intake_resolves_updated_secret_reference(clean_project): +def test_webhook_intake_resolves_updated_secret_reference( + clean_project, +): """Webhook intake resolves the current referenced secret value.""" store = _require_rest_store(clean_project) secret_name = sample_name("webhook-intake-reference") @@ -118,141 +142,72 @@ def test_webhook_intake_resolves_updated_secret_reference(clean_project): clean_project.delete_secret(secret_name) -def test_webhook_intake_records_auth_failures_for_active_integrations( +@pytest.mark.parametrize( + ( + "scenario", + "expected_status", + "expected_counts", + "expected_error", + ), + [ + ("auth-failure", 401, (1, 0, 1, 0), "Invalid webhook signature."), + ( + "invalid-payload", + 400, + (1, 0, 0, 1), + "Request body must be valid JSON.", + ), + ("inactive", 409, (0, 0, 0, 0), None), + ("type-mismatch", 404, (0, 0, 0, 0), None), + ], +) +def test_webhook_intake_failure_scenarios( clean_project, + webhook_integration_factory, + scenario: str, + expected_status: int, + expected_counts: tuple[int, int, int, int], + expected_error: str | None, ): store = _require_rest_store(clean_project) - result = clean_project.create_webhook_integration( - name=sample_name("webhook-intake-auth"), - webhook_type=WebhookType.CUSTOM, - ) - integration = result.integration - body = b'{"pipeline":"training"}' - - try: - response = _post_webhook( - store=store, - endpoint_path=integration.endpoint_path, - body=body, - headers={ - "X-ZenML-Event": "pipeline.ready", - "X-ZenML-Signature-256": "sha256=invalid", - }, - ) - - assert response.status_code == 401 - - updated = clean_project.get_webhook_integration(integration.id) - assert updated.stats.received_count == 1 - assert updated.stats.accepted_count == 0 - assert updated.stats.auth_failed_count == 1 - assert updated.stats.invalid_payload_count == 0 - assert updated.stats.last_error_at is not None - assert updated.stats.last_error_summary == "Invalid webhook signature." - finally: - clean_project.delete_webhook_integration(integration.id) - - -def test_webhook_intake_records_invalid_payload_after_auth(clean_project): - store = _require_rest_store(clean_project) - result = clean_project.create_webhook_integration( - name=sample_name("webhook-intake-payload"), - webhook_type=WebhookType.CUSTOM, + result = webhook_integration_factory( + f"webhook-intake-{scenario}", + active=scenario != "inactive", ) integration = result.integration assert result.secret is not None secret = result.secret.get_secret_value() - body = b"not-json" - - try: - response = _post_webhook( - store=store, - endpoint_path=integration.endpoint_path, - body=body, - headers={ - "X-ZenML-Event": "pipeline.ready", - "X-ZenML-Signature-256": _signature(secret, body), - }, - ) + body = b"not-json" if scenario == "invalid-payload" else b'{"ok":true}' + endpoint_path = integration.endpoint_path + headers = { + "X-ZenML-Event": "pipeline.ready", + "X-ZenML-Signature-256": _signature(secret, body), + } + if scenario == "auth-failure": + headers["X-ZenML-Signature-256"] = "sha256=invalid" + elif scenario == "type-mismatch": + endpoint_path = endpoint_path.replace("/custom/", "/github/") + headers = {} - assert response.status_code == 400 - - updated = clean_project.get_webhook_integration(integration.id) - assert updated.stats.received_count == 1 - assert updated.stats.accepted_count == 0 - assert updated.stats.auth_failed_count == 0 - assert updated.stats.invalid_payload_count == 1 - assert updated.stats.last_error_at is not None - assert ( - updated.stats.last_error_summary - == "Request body must be valid JSON." - ) - finally: - clean_project.delete_webhook_integration(integration.id) - - -def test_webhook_intake_rejects_inactive_integration_without_stats( - clean_project, -): - store = _require_rest_store(clean_project) - result = clean_project.create_webhook_integration( - name=sample_name("webhook-intake-inactive"), - webhook_type=WebhookType.CUSTOM, - active=False, + response = _post_webhook( + store=store, + endpoint_path=endpoint_path, + body=body, + headers=headers, ) - integration = result.integration - assert result.secret is not None - secret = result.secret.get_secret_value() - body = b'{"pipeline":"training"}' - try: - response = _post_webhook( - store=store, - endpoint_path=integration.endpoint_path, - body=body, - headers={ - "X-ZenML-Event": "pipeline.ready", - "X-ZenML-Signature-256": _signature(secret, body), - }, - ) - - assert response.status_code == 409 - - updated = clean_project.get_webhook_integration(integration.id) - assert updated.stats.received_count == 0 - assert updated.stats.accepted_count == 0 - assert updated.stats.auth_failed_count == 0 - assert updated.stats.invalid_payload_count == 0 - finally: - clean_project.delete_webhook_integration(integration.id) - - -def test_webhook_intake_does_not_record_type_mismatch(clean_project): - store = _require_rest_store(clean_project) - result = clean_project.create_webhook_integration( - name=sample_name("webhook-intake-mismatch"), - webhook_type=WebhookType.CUSTOM, + assert response.status_code == expected_status + updated = clean_project.get_webhook_integration(integration.id) + assert ( + updated.stats.received_count, + updated.stats.accepted_count, + updated.stats.auth_failed_count, + updated.stats.invalid_payload_count, + ) == expected_counts + assert updated.stats.last_error_summary == expected_error + assert (updated.stats.last_error_at is not None) == ( + expected_error is not None ) - integration = result.integration - endpoint_path = integration.endpoint_path.replace("/custom/", "/github/") - - try: - response = _post_webhook( - store=store, - endpoint_path=endpoint_path, - body=b'{"pipeline":"training"}', - headers={}, - ) - - assert response.status_code == 404 - - updated = clean_project.get_webhook_integration(integration.id) - assert updated.stats.received_count == 0 - assert updated.stats.accepted_count == 0 - assert updated.stats.auth_failed_count == 0 - assert updated.stats.invalid_payload_count == 0 - finally: - clean_project.delete_webhook_integration(integration.id) def test_webhook_intake_returns_not_found_for_unknown_integration( diff --git a/tests/integration/functional/webhooks/test_zen_store.py b/tests/integration/functional/webhooks/test_zen_store.py index e018a268f5e..7f9404f7e35 100644 --- a/tests/integration/functional/webhooks/test_zen_store.py +++ b/tests/integration/functional/webhooks/test_zen_store.py @@ -79,31 +79,6 @@ def test_zen_store_webhook_integration_lifecycle(clean_client): store.get_webhook_integration(integration.id) -def test_zen_store_does_not_echo_user_supplied_webhook_secret(clean_client): - store = clean_client.zen_store - project_id = clean_client.active_project.id - name = sample_name("webhook-store-secret") - - result = store.create_webhook_integration( - WebhookIntegrationRequest( - project=project_id, - name=name, - webhook_type=WebhookType.GITHUB, - secret="user-supplied-secret", - ) - ) - - try: - assert result.secret is None - - integration = store.get_webhook_integration(result.integration.id) - - assert "secret" not in integration.model_dump() - assert integration.webhook_type == WebhookType.GITHUB - finally: - store.delete_webhook_integration(result.integration.id) - - def test_sql_store_resolves_webhook_secret_references_lazily(clean_client): """Webhook secret references resolve their current value at intake time.""" store = clean_client.zen_store diff --git a/tests/unit/zen_server/endpoints/test_webhook_integration_endpoints.py b/tests/unit/zen_server/endpoints/test_webhook_integration_endpoints.py index 0d34d6dd31e..ce6d937a99c 100644 --- a/tests/unit/zen_server/endpoints/test_webhook_integration_endpoints.py +++ b/tests/unit/zen_server/endpoints/test_webhook_integration_endpoints.py @@ -144,160 +144,103 @@ def update_webhook_integration(self, integration_id, update): ] -def test_receive_webhook_event_returns_404_for_missing_integration( - monkeypatch, -) -> None: - integration_id = uuid4() - store = _Store(integration=None) - adapter = _Adapter() - _install_dependencies(monkeypatch, store, adapter) - - with pytest.raises(HTTPException) as error: - _receive(integration_id) - - assert error.value.status_code == status.HTTP_404_NOT_FOUND - assert store.secret_requests == 0 - assert store.records == [] - assert adapter.authenticate_calls == 0 - assert adapter.parse_calls == 0 - - -def test_receive_webhook_event_returns_404_for_provider_type_mismatch( - monkeypatch, -) -> None: - integration_id = uuid4() - store = _Store( - integration=SimpleNamespace( - webhook_type=WebhookType.GITHUB, active=True - ) - ) - adapter = _Adapter() - _install_dependencies(monkeypatch, store, adapter) - - with pytest.raises(HTTPException) as error: - _receive(integration_id) - - assert error.value.status_code == status.HTTP_404_NOT_FOUND - assert store.secret_requests == 0 - assert store.records == [] - assert adapter.authenticate_calls == 0 - assert adapter.parse_calls == 0 - - -def test_receive_webhook_event_records_auth_failure_for_active_integration( - monkeypatch, -) -> None: - integration_id = uuid4() - store = _Store( - integration=SimpleNamespace( - webhook_type=WebhookType.CUSTOM, active=True - ) - ) - adapter = _Adapter(auth_error=WebhookAuthenticationError("bad auth")) - _install_dependencies(monkeypatch, store, adapter) - - with pytest.raises(HTTPException) as error: - _receive(integration_id) - - assert error.value.status_code == status.HTTP_401_UNAUTHORIZED - assert error.value.detail == "Invalid webhook authentication." - assert store.secret_requests == 1 - assert len(store.records) == 1 - recorded_id, update = store.records[0] - assert recorded_id == integration_id - assert update.auth_failed is True - assert update.error_summary == "bad auth" - assert adapter.authenticate_calls == 1 - assert adapter.parse_calls == 0 - - -def test_receive_webhook_event_does_not_record_auth_failure_for_inactive_integration( - monkeypatch, -) -> None: - integration_id = uuid4() - store = _Store( - integration=SimpleNamespace( - webhook_type=WebhookType.CUSTOM, active=False - ) - ) - adapter = _Adapter(auth_error=WebhookAuthenticationError("bad auth")) - _install_dependencies(monkeypatch, store, adapter) - - with pytest.raises(HTTPException) as error: - _receive(integration_id) - - assert error.value.status_code == status.HTTP_401_UNAUTHORIZED - assert store.secret_requests == 1 - assert store.records == [] - assert adapter.authenticate_calls == 1 - assert adapter.parse_calls == 0 - - -def test_receive_webhook_event_returns_409_for_inactive_integration_after_auth( +@pytest.mark.parametrize( + ( + "stored_type", + "active", + "auth_error", + "payload_error", + "expected_status", + "expected_outcome", + "expected_error", + ), + [ + (None, None, None, None, 404, None, None), + (WebhookType.GITHUB, True, None, None, 404, None, None), + ( + WebhookType.CUSTOM, + True, + WebhookAuthenticationError("bad auth"), + None, + 401, + "auth_failed", + "bad auth", + ), + ( + WebhookType.CUSTOM, + False, + WebhookAuthenticationError("bad auth"), + None, + 401, + None, + None, + ), + (WebhookType.CUSTOM, False, None, None, 409, None, None), + ( + WebhookType.CUSTOM, + True, + None, + WebhookPayloadError("bad payload"), + 400, + "invalid_payload", + "bad payload", + ), + (WebhookType.CUSTOM, True, None, None, 202, "accepted", None), + ], + ids=[ + "missing-integration", + "provider-mismatch", + "active-auth-failure", + "inactive-auth-failure", + "inactive-authenticated", + "invalid-payload", + "accepted", + ], +) +def test_receive_webhook_event_decision_table( monkeypatch, + stored_type: WebhookType | None, + active: bool | None, + auth_error: Exception | None, + payload_error: Exception | None, + expected_status: int, + expected_outcome: str | None, + expected_error: str | None, ) -> None: integration_id = uuid4() store = _Store( - integration=SimpleNamespace( - webhook_type=WebhookType.CUSTOM, active=False + integration=( + SimpleNamespace(webhook_type=stored_type, active=active) + if stored_type is not None + else None ) ) - adapter = _Adapter() + adapter = _Adapter(auth_error=auth_error, payload_error=payload_error) _install_dependencies(monkeypatch, store, adapter) - with pytest.raises(HTTPException) as error: - _receive(integration_id) - - assert error.value.status_code == status.HTTP_409_CONFLICT - assert store.secret_requests == 1 - assert store.records == [] - assert adapter.authenticate_calls == 1 - assert adapter.parse_calls == 0 - - -def test_receive_webhook_event_records_invalid_payload_after_auth( - monkeypatch, -) -> None: - integration_id = uuid4() - store = _Store( - integration=SimpleNamespace( - webhook_type=WebhookType.CUSTOM, active=True - ) - ) - adapter = _Adapter(payload_error=WebhookPayloadError("bad payload")) - _install_dependencies(monkeypatch, store, adapter) - - with pytest.raises(HTTPException) as error: - _receive(integration_id) - - assert error.value.status_code == status.HTTP_400_BAD_REQUEST - assert store.secret_requests == 1 - assert len(store.records) == 1 - recorded_id, update = store.records[0] - assert recorded_id == integration_id - assert update.invalid_payload is True - assert update.error_summary == "bad payload" - assert adapter.authenticate_calls == 1 - assert adapter.parse_calls == 1 - - -def test_receive_webhook_event_records_accepted_event(monkeypatch) -> None: - integration_id = uuid4() - store = _Store( - integration=SimpleNamespace( - webhook_type=WebhookType.CUSTOM, active=True - ) - ) - adapter = _Adapter() - _install_dependencies(monkeypatch, store, adapter) - - response = _receive(integration_id) - - assert response.status_code == status.HTTP_202_ACCEPTED - assert store.secret_requests == 1 - assert len(store.records) == 1 - recorded_id, update = store.records[0] - assert recorded_id == integration_id - assert update.accepted is True - assert adapter.authenticate_calls == 1 - assert adapter.parse_calls == 1 + if expected_status == status.HTTP_202_ACCEPTED: + assert _receive(integration_id).status_code == expected_status + else: + with pytest.raises(HTTPException) as error: + _receive(integration_id) + assert error.value.status_code == expected_status + if auth_error is not None: + assert error.value.detail == "Invalid webhook authentication." + + resolved = stored_type == WebhookType.CUSTOM + parsed = expected_status in { + status.HTTP_202_ACCEPTED, + status.HTTP_400_BAD_REQUEST, + } + assert store.secret_requests == int(resolved) + assert adapter.authenticate_calls == int(resolved) + assert adapter.parse_calls == int(parsed) + + if expected_outcome is None: + assert store.records == [] + else: + assert len(store.records) == 1 + recorded_id, update = store.records[0] + assert recorded_id == integration_id + assert getattr(update, expected_outcome) is True + assert update.error_summary == expected_error diff --git a/tests/unit/zen_stores/schemas/test_webhook_integration_schemas.py b/tests/unit/zen_stores/schemas/test_webhook_integration_schemas.py index 3f5c4f05024..274bf8eaed4 100644 --- a/tests/unit/zen_stores/schemas/test_webhook_integration_schemas.py +++ b/tests/unit/zen_stores/schemas/test_webhook_integration_schemas.py @@ -4,7 +4,7 @@ from uuid import uuid4 import pytest -from pydantic import ValidationError +from pydantic import BaseModel, ValidationError from zenml.constants import API, VERSION_1, WEBHOOKS from zenml.enums import WebhookType @@ -42,25 +42,6 @@ def _webhook_integration_schema() -> WebhookIntegrationSchema: ) -@pytest.mark.parametrize( - "update", - [ - WebhookEventStatsUpdate(accepted=True), - WebhookEventStatsUpdate(auth_failed=True, error_summary="bad auth"), - WebhookEventStatsUpdate( - invalid_payload=True, error_summary="bad payload" - ), - ], -) -def test_webhook_event_stats_update_accepts_single_outcome( - update: WebhookEventStatsUpdate, -) -> None: - """Webhook stats updates accept exactly one terminal outcome.""" - assert ( - sum([update.accepted, update.auth_failed, update.invalid_payload]) == 1 - ) - - @pytest.mark.parametrize( "kwargs", [ @@ -77,34 +58,31 @@ def test_webhook_event_stats_update_rejects_invalid_outcome( WebhookEventStatsUpdate(**kwargs) +@pytest.mark.parametrize( + ("model_class", "kwargs"), + [ + ( + WebhookIntegrationRequest, + { + "name": "github-intake", + "project": uuid4(), + "webhook_type": WebhookType.GITHUB, + }, + ), + (WebhookIntegrationSecretRequest, {}), + (WebhookIntegrationUpdate, {}), + ], + ids=["create", "rotate", "update"], +) @pytest.mark.parametrize("secret", ["", " "]) -def test_webhook_integration_request_rejects_empty_secret( - secret: str, -) -> None: - """Webhook integration creation rejects empty signing secrets.""" - with pytest.raises(ValidationError): - WebhookIntegrationRequest( - name="github-intake", - project=uuid4(), - webhook_type=WebhookType.GITHUB, - secret=secret, - ) - - -@pytest.mark.parametrize("secret", ["", " "]) -def test_webhook_integration_secret_request_rejects_empty_secret( +def test_webhook_integration_models_reject_empty_secret( + model_class: type[BaseModel], + kwargs: dict[str, object], secret: str, ) -> None: - """Webhook integration secret rotation rejects empty signing secrets.""" - with pytest.raises(ValidationError): - WebhookIntegrationSecretRequest(secret=secret) - - -@pytest.mark.parametrize("secret", ["", " "]) -def test_webhook_integration_update_rejects_empty_secret(secret: str) -> None: - """Webhook integration updates reject empty signing secrets.""" + """Webhook integration models reject empty signing secrets.""" with pytest.raises(ValidationError): - WebhookIntegrationUpdate(secret=secret) + model_class(secret=secret, **kwargs) def test_webhook_integration_requests_allow_missing_secret() -> None: From cda47ac19c06e85216a22a07ce3c77136c87f844 Mon Sep 17 00:00:00 2001 From: Iason Andriopoulos Date: Tue, 14 Jul 2026 13:12:34 +0300 Subject: [PATCH 11/18] Fix breaking zizmor checks --- .github/workflows/release.yml | 7 +++++-- .github/zizmor.yml | 11 ++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d8b87bfe7b8..f9ed239db82 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -153,12 +153,15 @@ jobs: run: echo "VERSION=${GITHUB_REF/refs\/tags\//}" >> "$GITHUB_OUTPUT" - name: Create a tag on ZenML Cloud plugins repo uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + VERSION: ${{ steps.get_version.outputs.VERSION }} + SHA: ${{ steps.get_sha.outputs.sha }} with: github-token: ${{ secrets.CLOUD_PLUGINS_REPO_PAT }} script: |- await github.rest.git.createRef({ owner: 'zenml-io', repo: 'zenml-cloud-plugins', - ref: 'refs/tags/${{ steps.get_version.outputs.VERSION }}', - sha: '${{ steps.get_sha.outputs.sha }}' + ref: `refs/tags/${process.env.VERSION}`, + sha: process.env.SHA }) diff --git a/.github/zizmor.yml b/.github/zizmor.yml index c0e3302da88..9878e3a265c 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -12,10 +12,10 @@ rules: # These are ZenML's own templates and are intentionally unpinned unpinned-uses: ignore: - - update-templates-to-examples.yml:44 # zenml-io/template-e2e-batch + - update-templates-to-examples.yml:46 # zenml-io/template-e2e-batch - update-templates-to-examples.yml:120 # zenml-io/template-nlp - - update-templates-to-examples.yml:195 # zenml-io/template-starter - - update-templates-to-examples.yml:272 # zenml-io/template-llm-finetuning + - update-templates-to-examples.yml:193 # zenml-io/template-starter + - update-templates-to-examples.yml:268 # zenml-io/template-llm-finetuning - weekly-agent-pipelines-test.yml:101 # Ilshidur/action-discord uses @master, can't be SHA-pinned # excessive-permissions: These workflows use default permissions. @@ -48,12 +48,9 @@ rules: disable: true # Detect dangerous template expansions - # Ignores: Low-confidence findings for step outputs used in github-script - # (step outputs within same workflow are trusted) + # Ignores: Low-confidence findings for trusted values used in github-script template-injection: ignore: - - release.yml:169 # VERSION from step output - - release.yml:170 # sha from step output - snack-it.yml:352 # issue_number from step output - snack-it.yml:353 # continuation of above - snack-it.yml:354 # project_added from step output From 1124d134344d844b4c2b05fb7524225595a3ad95 Mon Sep 17 00:00:00 2001 From: Iason Andriopoulos Date: Wed, 15 Jul 2026 13:40:58 +0300 Subject: [PATCH 12/18] Address PR review --- src/zenml/cli/webhook_integration.py | 14 ++++- src/zenml/client.py | 29 ++++++++--- src/zenml/models/__init__.py | 4 +- .../models/v2/core/webhook_integration.py | 22 ++++---- src/zenml/webhooks/__init__.py | 14 ++++- src/zenml/webhooks/adapters.py | 14 ++++- src/zenml/zen_server/rbac/utils.py | 2 + .../routers/webhook_integration_endpoints.py | 18 +++++-- .../7c0d9e4a1b2f_add_webhook_integrations.py | 29 ++++++----- src/zenml/zen_stores/rest_zen_store.py | 4 +- .../schemas/webhook_integration_schemas.py | 31 ++++++++--- src/zenml/zen_stores/sql_zen_store.py | 16 ++++-- src/zenml/zen_stores/zen_store_interface.py | 4 +- .../functional/webhooks/__init__.py | 13 +++++ .../functional/webhooks/test_cli.py | 23 ++++++++ .../functional/webhooks/test_client.py | 52 +++++++++++++++++++ .../webhooks/test_events_endpoint.py | 13 +++++ .../functional/webhooks/test_zen_store.py | 50 +++++++++++++++++- tests/unit/webhooks/test_adapters.py | 13 +++++ .../test_webhook_integration_endpoints.py | 13 +++++ .../test_webhook_integration_schemas.py | 19 +++++-- 21 files changed, 335 insertions(+), 62 deletions(-) diff --git a/src/zenml/cli/webhook_integration.py b/src/zenml/cli/webhook_integration.py index fb39d1159ca..7eb1865aaa2 100644 --- a/src/zenml/cli/webhook_integration.py +++ b/src/zenml/cli/webhook_integration.py @@ -1,4 +1,16 @@ -# Copyright (c) ZenML GmbH 2026. All Rights Reserved. +# Copyright (c) ZenML GmbH 2026. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing +# permissions and limitations under the License. """CLI commands for webhook integrations.""" from typing import Any diff --git a/src/zenml/client.py b/src/zenml/client.py index 8724e172e83..c58f49f3df9 100644 --- a/src/zenml/client.py +++ b/src/zenml/client.py @@ -220,7 +220,7 @@ WebhookIntegrationFilter, WebhookIntegrationRequest, WebhookIntegrationResponse, - WebhookIntegrationSecretRequest, + WebhookIntegrationRotateSecretRequest, WebhookIntegrationSecretResponse, WebhookIntegrationUpdate, ) @@ -6842,6 +6842,7 @@ def delete_code_repository( # ------------------------- Webhook integrations ------------------------- + @_fail_for_sql_zen_store def create_webhook_integration( self, name: str, @@ -6871,6 +6872,7 @@ def create_webhook_integration( ) ) + @_fail_for_sql_zen_store def get_webhook_integration( self, name_id_or_prefix: str | UUID, @@ -6898,6 +6900,7 @@ def get_webhook_integration( project=project, ) + @_fail_for_sql_zen_store def list_webhook_integrations( self, sort_by: str = "created", @@ -6952,6 +6955,7 @@ def list_webhook_integrations( filter_model=filter_model, hydrate=hydrate ) + @_fail_for_sql_zen_store def update_webhook_integration( self, name_id_or_prefix: str | UUID, @@ -6972,18 +6976,26 @@ def update_webhook_integration( Returns: The updated webhook integration. """ - integration = self.get_webhook_integration( - name_id_or_prefix=name_id_or_prefix, - allow_name_prefix_match=False, - project=project, - ) + if is_valid_uuid(name_id_or_prefix): + integration_id = ( + UUID(name_id_or_prefix) + if isinstance(name_id_or_prefix, str) + else name_id_or_prefix + ) + else: + integration_id = self.get_webhook_integration( + name_id_or_prefix=name_id_or_prefix, + allow_name_prefix_match=False, + project=project, + ).id return self.zen_store.update_webhook_integration( - integration_id=integration.id, + integration_id=integration_id, update=WebhookIntegrationUpdate( name=name, active=active, secret=secret ), ) + @_fail_for_sql_zen_store def delete_webhook_integration( self, name_id_or_prefix: str | UUID, @@ -7002,6 +7014,7 @@ def delete_webhook_integration( ) self.zen_store.delete_webhook_integration(integration.id) + @_fail_for_sql_zen_store def rotate_webhook_integration_secret( self, name_id_or_prefix: str | UUID, @@ -7025,7 +7038,7 @@ def rotate_webhook_integration_secret( ) return self.zen_store.rotate_webhook_integration_secret( integration_id=integration.id, - request=WebhookIntegrationSecretRequest(secret=secret), + request=WebhookIntegrationRotateSecretRequest(secret=secret), ) # --------------------------- Service Connectors --------------------------- diff --git a/src/zenml/models/__init__.py b/src/zenml/models/__init__.py index feb5e679d15..b8aab97ab41 100644 --- a/src/zenml/models/__init__.py +++ b/src/zenml/models/__init__.py @@ -441,7 +441,7 @@ WebhookIntegrationResponseBody, WebhookIntegrationResponseMetadata, WebhookIntegrationResponseResources, - WebhookIntegrationSecretRequest, + WebhookIntegrationRotateSecretRequest, WebhookIntegrationSecretResponse, WebhookIntegrationStats, WebhookIntegrationUpdate, @@ -1054,7 +1054,7 @@ "WebhookIntegrationResponseBody", "WebhookIntegrationResponseMetadata", "WebhookIntegrationResponseResources", - "WebhookIntegrationSecretRequest", + "WebhookIntegrationRotateSecretRequest", "WebhookIntegrationSecretResponse", "WebhookIntegrationStats", "WebhookIntegrationUpdate", diff --git a/src/zenml/models/v2/core/webhook_integration.py b/src/zenml/models/v2/core/webhook_integration.py index 2cf8b0daf85..8a288762eaa 100644 --- a/src/zenml/models/v2/core/webhook_integration.py +++ b/src/zenml/models/v2/core/webhook_integration.py @@ -1,16 +1,16 @@ -# Copyright (c) ZenML GmbH 2026. All Rights Reserved. +# Copyright (c) ZenML GmbH 2026. All Rights Reserved. # -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: # -# http://www.apache.org/licenses/LICENSE-2.0 +# https://www.apache.org/licenses/LICENSE-2.0 # -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing +# permissions and limitations under the License. """Models for project-scoped webhook integrations.""" from datetime import datetime @@ -171,7 +171,7 @@ class WebhookIntegrationCreateResponse(BaseZenModel): secret: PlainSerializedSecretStr | None = None -class WebhookIntegrationSecretRequest(BaseZenModel): +class WebhookIntegrationRotateSecretRequest(BaseZenModel): """Request model for rotating a webhook integration secret.""" secret: NonEmptyPlainSerializedSecretStr | None = Field( diff --git a/src/zenml/webhooks/__init__.py b/src/zenml/webhooks/__init__.py index b0e93477886..50cf05e1c68 100644 --- a/src/zenml/webhooks/__init__.py +++ b/src/zenml/webhooks/__init__.py @@ -1,4 +1,16 @@ -# Copyright (c) ZenML GmbH 2026. All Rights Reserved. +# Copyright (c) ZenML GmbH 2026. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing +# permissions and limitations under the License. """Webhook provider authentication and payload validation.""" from zenml.webhooks.adapters import ( diff --git a/src/zenml/webhooks/adapters.py b/src/zenml/webhooks/adapters.py index e4d0d7f7a26..9d07f6a6fcd 100644 --- a/src/zenml/webhooks/adapters.py +++ b/src/zenml/webhooks/adapters.py @@ -1,4 +1,16 @@ -# Copyright (c) ZenML GmbH 2026. All Rights Reserved. +# Copyright (c) ZenML GmbH 2026. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing +# permissions and limitations under the License. """Provider adapters for webhook authentication and basic validation.""" import hashlib diff --git a/src/zenml/zen_server/rbac/utils.py b/src/zenml/zen_server/rbac/utils.py index 02903baafa5..e7fc0a74420 100644 --- a/src/zenml/zen_server/rbac/utils.py +++ b/src/zenml/zen_server/rbac/utils.py @@ -718,6 +718,7 @@ def _get_resource_type_schema_mapping() -> Dict[ TagSchema, TriggerSchema, UserSchema, + WebhookIntegrationSchema, ) return { @@ -746,6 +747,7 @@ def _get_resource_type_schema_mapping() -> Dict[ ResourceType.SCHEDULE: ScheduleSchema, # ResourceType.USER: UserSchema, ResourceType.TRIGGER: TriggerSchema, + ResourceType.WEBHOOK_INTEGRATION: WebhookIntegrationSchema, ResourceType.RESOURCE_POOL_SUBJECT_POLICY: ResourcePoolSubjectPolicySchema, } diff --git a/src/zenml/zen_server/routers/webhook_integration_endpoints.py b/src/zenml/zen_server/routers/webhook_integration_endpoints.py index 6f4c91f5e30..800b68d47c4 100644 --- a/src/zenml/zen_server/routers/webhook_integration_endpoints.py +++ b/src/zenml/zen_server/routers/webhook_integration_endpoints.py @@ -1,4 +1,16 @@ -# Copyright (c) ZenML GmbH 2026. All Rights Reserved. +# Copyright (c) ZenML GmbH 2026. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing +# permissions and limitations under the License. """Management and public intake endpoints for webhook integrations.""" from uuid import UUID @@ -30,7 +42,7 @@ WebhookIntegrationFilter, WebhookIntegrationRequest, WebhookIntegrationResponse, - WebhookIntegrationSecretRequest, + WebhookIntegrationRotateSecretRequest, WebhookIntegrationSecretResponse, WebhookIntegrationUpdate, ) @@ -210,7 +222,7 @@ def delete_webhook_integration( @async_fastapi_endpoint_wrapper def rotate_webhook_integration_secret( integration_id: UUID, - request: WebhookIntegrationSecretRequest, + request: WebhookIntegrationRotateSecretRequest, _: AuthContext = Security(authorize), ) -> WebhookIntegrationSecretResponse: """Rotate a webhook integration signing secret. diff --git a/src/zenml/zen_stores/migrations/versions/7c0d9e4a1b2f_add_webhook_integrations.py b/src/zenml/zen_stores/migrations/versions/7c0d9e4a1b2f_add_webhook_integrations.py index 4418a7767b6..e5a9d567603 100644 --- a/src/zenml/zen_stores/migrations/versions/7c0d9e4a1b2f_add_webhook_integrations.py +++ b/src/zenml/zen_stores/migrations/versions/7c0d9e4a1b2f_add_webhook_integrations.py @@ -1,3 +1,16 @@ +# Copyright (c) ZenML GmbH 2026. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing +# permissions and limitations under the License. """Add webhook integrations [7c0d9e4a1b2f]. Revision ID: 7c0d9e4a1b2f @@ -56,19 +69,13 @@ def upgrade() -> None: ), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint( - "name", "project_id", + "name", name="unique_webhook_integration_name_in_project", ), ) op.create_index( - op.f("ix_webhook_integration_active"), - "webhook_integration", - ["active"], - unique=False, - ) - op.create_index( - op.f("ix_webhook_integration_webhook_type"), + "ix_webhook_integration_webhook_type", "webhook_integration", ["webhook_type"], unique=False, @@ -78,11 +85,7 @@ def upgrade() -> None: def downgrade() -> None: """Drop the webhook integration table.""" op.drop_index( - op.f("ix_webhook_integration_webhook_type"), - table_name="webhook_integration", - ) - op.drop_index( - op.f("ix_webhook_integration_active"), + "ix_webhook_integration_webhook_type", table_name="webhook_integration", ) op.drop_table("webhook_integration") diff --git a/src/zenml/zen_stores/rest_zen_store.py b/src/zenml/zen_stores/rest_zen_store.py index 70d97f7a64d..50849d0032d 100644 --- a/src/zenml/zen_stores/rest_zen_store.py +++ b/src/zenml/zen_stores/rest_zen_store.py @@ -312,7 +312,7 @@ WebhookIntegrationFilter, WebhookIntegrationRequest, WebhookIntegrationResponse, - WebhookIntegrationSecretRequest, + WebhookIntegrationRotateSecretRequest, WebhookIntegrationSecretResponse, WebhookIntegrationUpdate, ) @@ -2672,7 +2672,7 @@ def delete_webhook_integration(self, integration_id: UUID) -> None: def rotate_webhook_integration_secret( self, integration_id: UUID, - request: WebhookIntegrationSecretRequest, + request: WebhookIntegrationRotateSecretRequest, ) -> WebhookIntegrationSecretResponse: """Rotate a webhook integration signing secret. diff --git a/src/zenml/zen_stores/schemas/webhook_integration_schemas.py b/src/zenml/zen_stores/schemas/webhook_integration_schemas.py index bd6ddfa4b0c..ac8fd6672c9 100644 --- a/src/zenml/zen_stores/schemas/webhook_integration_schemas.py +++ b/src/zenml/zen_stores/schemas/webhook_integration_schemas.py @@ -1,8 +1,16 @@ -# Copyright (c) ZenML GmbH 2026. All Rights Reserved. +# Copyright (c) ZenML GmbH 2026. All Rights Reserved. # -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing +# permissions and limitations under the License. """SQL schema for webhook integrations.""" from typing import Any @@ -24,7 +32,10 @@ from zenml.utils.time_utils import utc_now from zenml.zen_stores.schemas.base_schemas import NamedSchema from zenml.zen_stores.schemas.project_schemas import ProjectSchema -from zenml.zen_stores.schemas.schema_utils import build_foreign_key_field +from zenml.zen_stores.schemas.schema_utils import ( + build_foreign_key_field, + build_index, +) from zenml.zen_stores.schemas.secret_schemas import SecretSchema from zenml.zen_stores.schemas.user_schemas import UserSchema @@ -35,10 +46,14 @@ class WebhookIntegrationSchema(NamedSchema, table=True): __tablename__ = "webhook_integration" __table_args__ = ( UniqueConstraint( - "name", "project_id", + "name", name="unique_webhook_integration_name_in_project", ), + build_index( + table_name=__tablename__, + column_names=["webhook_type"], + ), ) project_id: UUID = build_foreign_key_field( @@ -69,8 +84,8 @@ class WebhookIntegrationSchema(NamedSchema, table=True): ondelete="CASCADE", nullable=False, ) - webhook_type: str = Field(index=True) - active: bool = Field(default=True, index=True) + webhook_type: str + active: bool = Field(default=True) stats: str = Field( default=WebhookIntegrationStats().model_dump_json(), sa_column=Column(TEXT, nullable=False), diff --git a/src/zenml/zen_stores/sql_zen_store.py b/src/zenml/zen_stores/sql_zen_store.py index afabd298c91..6118bc43b7c 100644 --- a/src/zenml/zen_stores/sql_zen_store.py +++ b/src/zenml/zen_stores/sql_zen_store.py @@ -380,7 +380,7 @@ WebhookIntegrationFilter, WebhookIntegrationRequest, WebhookIntegrationResponse, - WebhookIntegrationSecretRequest, + WebhookIntegrationRotateSecretRequest, WebhookIntegrationSecretResponse, WebhookIntegrationUpdate, ) @@ -8238,7 +8238,9 @@ def create_webhook_integration( ) raise return WebhookIntegrationCreateResponse( - integration=schema.to_model(include_metadata=True), + integration=schema.to_model( + include_metadata=True, include_resources=True + ), secret=( PlainSerializedSecretStr(secret) if generated_secret @@ -8264,7 +8266,9 @@ def get_webhook_integration( schema_class=WebhookIntegrationSchema, session=session, ) - return schema.to_model(include_metadata=hydrate) + return schema.to_model( + include_metadata=hydrate, include_resources=True + ) def list_webhook_integrations( self, @@ -8327,7 +8331,9 @@ def update_webhook_integration( schema.update(update) session.add(schema) session.commit() - return schema.to_model(include_metadata=True) + return schema.to_model( + include_metadata=True, include_resources=True + ) def delete_webhook_integration(self, integration_id: UUID) -> None: """Delete a webhook integration and its internal secret. @@ -8348,7 +8354,7 @@ def delete_webhook_integration(self, integration_id: UUID) -> None: def rotate_webhook_integration_secret( self, integration_id: UUID, - request: WebhookIntegrationSecretRequest, + request: WebhookIntegrationRotateSecretRequest, ) -> WebhookIntegrationSecretResponse: """Replace the active webhook signing secret. diff --git a/src/zenml/zen_stores/zen_store_interface.py b/src/zenml/zen_stores/zen_store_interface.py index 7e347dc44d4..7daf7bd69d3 100644 --- a/src/zenml/zen_stores/zen_store_interface.py +++ b/src/zenml/zen_stores/zen_store_interface.py @@ -171,7 +171,7 @@ WebhookIntegrationFilter, WebhookIntegrationRequest, WebhookIntegrationResponse, - WebhookIntegrationSecretRequest, + WebhookIntegrationRotateSecretRequest, WebhookIntegrationSecretResponse, WebhookIntegrationUpdate, ) @@ -1904,7 +1904,7 @@ def delete_webhook_integration(self, integration_id: UUID) -> None: def rotate_webhook_integration_secret( self, integration_id: UUID, - request: WebhookIntegrationSecretRequest, + request: WebhookIntegrationRotateSecretRequest, ) -> WebhookIntegrationSecretResponse: """Rotate a webhook integration signing secret. diff --git a/tests/integration/functional/webhooks/__init__.py b/tests/integration/functional/webhooks/__init__.py index 644506ccc39..dbee9dcabb7 100644 --- a/tests/integration/functional/webhooks/__init__.py +++ b/tests/integration/functional/webhooks/__init__.py @@ -1 +1,14 @@ +# Copyright (c) ZenML GmbH 2026. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing +# permissions and limitations under the License. """Functional tests for webhook integrations.""" diff --git a/tests/integration/functional/webhooks/test_cli.py b/tests/integration/functional/webhooks/test_cli.py index a01d9e519d5..4ee3e6b5f53 100644 --- a/tests/integration/functional/webhooks/test_cli.py +++ b/tests/integration/functional/webhooks/test_cli.py @@ -1,3 +1,16 @@ +# Copyright (c) ZenML GmbH 2026. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing +# permissions and limitations under the License. import io from unittest.mock import patch @@ -9,6 +22,16 @@ from zenml.cli.cli import cli from zenml.client import Client from zenml.enums import WebhookType +from zenml.zen_stores.sql_zen_store import SqlZenStore + + +@pytest.fixture +def clean_client(clean_project: Client) -> Client: + """Return the active client when it is connected to a server.""" + if isinstance(clean_project.zen_store, SqlZenStore): + pytest.skip("Webhook integrations require a REST store.") + return clean_project + webhook_integration_command = cli.commands["webhook-integration"] create_command = webhook_integration_command.commands["create"] diff --git a/tests/integration/functional/webhooks/test_client.py b/tests/integration/functional/webhooks/test_client.py index 91861718c33..976a3fcd24a 100644 --- a/tests/integration/functional/webhooks/test_client.py +++ b/tests/integration/functional/webhooks/test_client.py @@ -1,8 +1,31 @@ +# Copyright (c) ZenML GmbH 2026. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing +# permissions and limitations under the License. import pytest from tests.integration.functional.utils import sample_name +from zenml.client import Client from zenml.enums import WebhookType from zenml.exceptions import IllegalOperationError +from zenml.zen_stores.sql_zen_store import SqlZenStore + + +@pytest.fixture +def clean_client(clean_project: Client) -> Client: + """Return the active client when it is connected to a server.""" + if isinstance(clean_project.zen_store, SqlZenStore): + pytest.skip("Webhook integrations require a REST store.") + return clean_project def test_client_webhook_integration_lifecycle(clean_client): @@ -93,6 +116,35 @@ def test_client_does_not_echo_user_supplied_webhook_secret(clean_client): clean_client.delete_webhook_integration(result.integration.id) +def test_client_update_webhook_integration_by_name_and_id( + clean_client, +) -> None: + """Webhook integrations can be updated by name and ID.""" + name = sample_name("webhook-client-update") + result = clean_client.create_webhook_integration( + name=name, + webhook_type=WebhookType.CUSTOM, + ) + integration_id = result.integration.id + + try: + updated_by_name = clean_client.update_webhook_integration( + name, + active=False, + ) + updated_by_id = clean_client.update_webhook_integration( + integration_id, + active=True, + ) + + assert updated_by_name.id == integration_id + assert updated_by_name.active is False + assert updated_by_id.id == integration_id + assert updated_by_id.active is True + finally: + clean_client.delete_webhook_integration(integration_id) + + def test_client_rejects_rotation_for_referenced_webhook_secret(clean_client): """Referenced webhook secrets can be updated but not rotated.""" integration_name = sample_name("webhook-client-reference") diff --git a/tests/integration/functional/webhooks/test_events_endpoint.py b/tests/integration/functional/webhooks/test_events_endpoint.py index 8a7b011fbb9..5da395ba265 100644 --- a/tests/integration/functional/webhooks/test_events_endpoint.py +++ b/tests/integration/functional/webhooks/test_events_endpoint.py @@ -1,3 +1,16 @@ +# Copyright (c) ZenML GmbH 2026. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing +# permissions and limitations under the License. import hashlib import hmac from uuid import uuid4 diff --git a/tests/integration/functional/webhooks/test_zen_store.py b/tests/integration/functional/webhooks/test_zen_store.py index 7f9404f7e35..a71177e8ede 100644 --- a/tests/integration/functional/webhooks/test_zen_store.py +++ b/tests/integration/functional/webhooks/test_zen_store.py @@ -1,3 +1,16 @@ +# Copyright (c) ZenML GmbH 2026. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing +# permissions and limitations under the License. import pytest from tests.integration.functional.utils import sample_name @@ -5,12 +18,37 @@ from zenml.models import ( WebhookIntegrationFilter, WebhookIntegrationRequest, - WebhookIntegrationSecretRequest, + WebhookIntegrationRotateSecretRequest, WebhookIntegrationUpdate, ) from zenml.zen_stores.sql_zen_store import SqlZenStore +def test_client_webhook_integration_methods_require_rest_store( + clean_client, +) -> None: + """Public webhook integration client methods reject local SQL stores.""" + if not isinstance(clean_client.zen_store, SqlZenStore): + pytest.skip("Local SQL store behavior is required for this test.") + + error = "This method is not allowed when not connected" + with pytest.raises(TypeError, match=error): + clean_client.create_webhook_integration( + name="webhook", + webhook_type=WebhookType.CUSTOM, + ) + with pytest.raises(TypeError, match=error): + clean_client.get_webhook_integration("webhook") + with pytest.raises(TypeError, match=error): + clean_client.list_webhook_integrations() + with pytest.raises(TypeError, match=error): + clean_client.update_webhook_integration("webhook", active=False) + with pytest.raises(TypeError, match=error): + clean_client.delete_webhook_integration("webhook") + with pytest.raises(TypeError, match=error): + clean_client.rotate_webhook_integration_secret("webhook") + + def test_zen_store_webhook_integration_lifecycle(clean_client): store = clean_client.zen_store project_id = clean_client.active_project.id @@ -32,11 +70,15 @@ def test_zen_store_webhook_integration_lifecycle(clean_client): assert integration.webhook_type == WebhookType.CUSTOM assert integration.active is True assert integration.stats.received_count == 0 + assert integration.get_resources().user is not None + assert integration.get_resources().user.id == clean_client.active_user.id by_id = store.get_webhook_integration(integration.id) assert by_id.id == integration.id assert by_id.stats.received_count == 0 + assert by_id.get_resources().user is not None + assert by_id.get_resources().user.id == clean_client.active_user.id filtered = store.list_webhook_integrations( WebhookIntegrationFilter( @@ -59,6 +101,8 @@ def test_zen_store_webhook_integration_lifecycle(clean_client): assert updated.id == integration.id assert updated.name == updated_name assert updated.active is False + assert updated.get_resources().user is not None + assert updated.get_resources().user.id == clean_client.active_user.id inactive_integrations = store.list_webhook_integrations( WebhookIntegrationFilter(project=project_id, active=False) @@ -68,7 +112,9 @@ def test_zen_store_webhook_integration_lifecycle(clean_client): rotated = store.rotate_webhook_integration_secret( integration_id=integration.id, - request=WebhookIntegrationSecretRequest(secret="replacement-secret"), + request=WebhookIntegrationRotateSecretRequest( + secret="replacement-secret" + ), ) assert rotated.secret.get_secret_value() == "replacement-secret" diff --git a/tests/unit/webhooks/test_adapters.py b/tests/unit/webhooks/test_adapters.py index 8b97e52306f..e733a7b7993 100644 --- a/tests/unit/webhooks/test_adapters.py +++ b/tests/unit/webhooks/test_adapters.py @@ -1,3 +1,16 @@ +# Copyright (c) ZenML GmbH 2026. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing +# permissions and limitations under the License. import hashlib import hmac from collections.abc import Mapping diff --git a/tests/unit/zen_server/endpoints/test_webhook_integration_endpoints.py b/tests/unit/zen_server/endpoints/test_webhook_integration_endpoints.py index ce6d937a99c..25212af1ec1 100644 --- a/tests/unit/zen_server/endpoints/test_webhook_integration_endpoints.py +++ b/tests/unit/zen_server/endpoints/test_webhook_integration_endpoints.py @@ -1,3 +1,16 @@ +# Copyright (c) ZenML GmbH 2026. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing +# permissions and limitations under the License. from types import SimpleNamespace from uuid import uuid4 diff --git a/tests/unit/zen_stores/schemas/test_webhook_integration_schemas.py b/tests/unit/zen_stores/schemas/test_webhook_integration_schemas.py index 274bf8eaed4..f07412ed0d4 100644 --- a/tests/unit/zen_stores/schemas/test_webhook_integration_schemas.py +++ b/tests/unit/zen_stores/schemas/test_webhook_integration_schemas.py @@ -1,3 +1,16 @@ +# Copyright (c) ZenML GmbH 2026. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing +# permissions and limitations under the License. """Unit tests for webhook integration schemas and request models.""" from datetime import datetime @@ -11,7 +24,7 @@ from zenml.models import ( WebhookEventStatsUpdate, WebhookIntegrationRequest, - WebhookIntegrationSecretRequest, + WebhookIntegrationRotateSecretRequest, WebhookIntegrationStats, WebhookIntegrationUpdate, ) @@ -69,7 +82,7 @@ def test_webhook_event_stats_update_rejects_invalid_outcome( "webhook_type": WebhookType.GITHUB, }, ), - (WebhookIntegrationSecretRequest, {}), + (WebhookIntegrationRotateSecretRequest, {}), (WebhookIntegrationUpdate, {}), ], ids=["create", "rotate", "update"], @@ -92,7 +105,7 @@ def test_webhook_integration_requests_allow_missing_secret() -> None: project=uuid4(), webhook_type=WebhookType.GITHUB, ) - secret_request = WebhookIntegrationSecretRequest() + secret_request = WebhookIntegrationRotateSecretRequest() assert integration_request.secret is None assert secret_request.secret is None From c252298f232f9fe57304bad72e889d7caf2e7baa Mon Sep 17 00:00:00 2001 From: Iason Andriopoulos Date: Wed, 15 Jul 2026 15:26:54 +0300 Subject: [PATCH 13/18] Simplify naming: webhook integration -> webhook --- src/zenml/cli/webhook_integration.py | 90 +++++++++---------- src/zenml/client.py | 58 ++++++------ src/zenml/constants.py | 1 - .../models/v2/core/webhook_integration.py | 2 +- .../routers/webhook_integration_endpoints.py | 85 +++++++++--------- src/zenml/zen_stores/rest_zen_store.py | 16 ++-- src/zenml/zen_stores/sql_zen_store.py | 4 +- .../functional/webhooks/test_cli.py | 32 +++---- .../functional/webhooks/test_client.py | 70 +++++++-------- .../webhooks/test_events_endpoint.py | 20 ++--- .../functional/webhooks/test_zen_store.py | 24 ++--- .../test_webhook_integration_endpoints.py | 13 ++- 12 files changed, 203 insertions(+), 212 deletions(-) diff --git a/src/zenml/cli/webhook_integration.py b/src/zenml/cli/webhook_integration.py index 7eb1865aaa2..b9bf8856138 100644 --- a/src/zenml/cli/webhook_integration.py +++ b/src/zenml/cli/webhook_integration.py @@ -11,7 +11,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express # or implied. See the License for the specific language governing # permissions and limitations under the License. -"""CLI commands for webhook integrations.""" +"""CLI commands for webhooks.""" from typing import Any @@ -27,14 +27,12 @@ from zenml.models import WebhookIntegrationFilter, WebhookIntegrationResponse -@cli.group( - "webhook-integration", cls=TagGroup, tag=CliCategories.MANAGEMENT_TOOLS -) -def webhook_integration() -> None: +@cli.group("webhook", cls=TagGroup, tag=CliCategories.MANAGEMENT_TOOLS) +def webhook() -> None: """Manage external webhook intake endpoints.""" -@webhook_integration.command("create") +@webhook.command("create") @click.argument("name", type=str) @click.option( "--type", @@ -49,29 +47,29 @@ def webhook_integration() -> None: help="Set a direct signing secret or ZenML secret reference.", ) @click.option("--inactive", is_flag=True, default=False) -def create_webhook_integration( +def create_webhook( name: str, webhook_type: str, secret: str | None, inactive: bool, ) -> None: - """Create a webhook integration. + """Create a webhook. Args: - name: The integration name. + name: The webhook name. webhook_type: The webhook provider type. secret: An optional user-supplied signing secret. - inactive: Whether to create the integration inactive. + inactive: Whether to create the webhook inactive. """ - result = Client().create_webhook_integration( + result = Client().create_webhook( name=name, webhook_type=WebhookType(webhook_type), active=not inactive, secret=secret, ) cli_utils.print_pydantic_model( - title=f"Webhook integration '{name}'", - model=result.integration, + title=f"Webhook '{name}'", + model=result.webhook, ) if result.secret is not None: cli_utils.declare( @@ -79,17 +77,17 @@ def create_webhook_integration( ) -@webhook_integration.command("describe") +@webhook.command("describe") @click.argument("name_or_id", type=str) -def describe_webhook_integration(name_or_id: str) -> None: - """Describe a webhook integration. +def describe_webhook(name_or_id: str) -> None: + """Describe a webhook. Args: - name_or_id: The integration name or ID. + name_or_id: The webhook name or ID. """ - integration = Client().get_webhook_integration(name_or_id) + integration = Client().get_webhook(name_or_id) cli_utils.print_pydantic_model( - title=f"Webhook integration '{integration.name}'", + title=f"Webhook '{integration.name}'", model=integration, ) cli_utils.declare(f"Endpoint path: {integration.endpoint_path}") @@ -111,33 +109,33 @@ def _format_webhook_integration_row( return {"project": integration.project.name} -@webhook_integration.command("list") +@webhook.command("list") @list_options( WebhookIntegrationFilter, default_columns=["id", "name", "webhook_type", "active", "project"], ) -def list_webhook_integrations( +def list_webhooks( columns: str, output_format: OutputFormat, **kwargs: Any ) -> None: - """List webhook integrations. + """List webhooks. Args: columns: The columns to display. output_format: The output format. - **kwargs: The webhook integration filters. + **kwargs: The webhook filters. """ - with console.status("Listing webhook integrations...\n"): - integrations = Client().list_webhook_integrations(**kwargs) + with console.status("Listing webhooks...\n"): + integrations = Client().list_webhooks(**kwargs) cli_utils.print_page( integrations, columns, output_format, row_formatter=_format_webhook_integration_row, - empty_message="No webhook integrations found for this filter.", + empty_message="No webhooks found for this filter.", ) -@webhook_integration.command("update") +@webhook.command("update") @click.argument("name_or_id", type=str) @click.option("--name", "new_name", type=str, default=None) @click.option( @@ -149,33 +147,33 @@ def list_webhook_integrations( default=None, help="Set a direct signing secret or ZenML secret reference.", ) -def update_webhook_integration( +def update_webhook( name_or_id: str, new_name: str | None, active: bool | None, secret: str | None, ) -> None: - """Update a webhook integration. + """Update a webhook. Args: - name_or_id: The integration name or ID. - new_name: The new integration name. + name_or_id: The webhook name or ID. + new_name: The new webhook name. active: The new active state. secret: A direct signing secret or ZenML secret reference. """ - integration = Client().update_webhook_integration( + integration = Client().update_webhook( name_id_or_prefix=name_or_id, name=new_name, active=active, secret=secret, ) cli_utils.print_pydantic_model( - title=f"Webhook integration '{integration.name}'", + title=f"Webhook '{integration.name}'", model=integration, ) -@webhook_integration.command("rotate-secret") +@webhook.command("rotate-secret") @click.argument("name_or_id", type=str) @click.option( "--secret", @@ -183,17 +181,15 @@ def update_webhook_integration( default=None, help="Set an optional direct replacement secret.", ) -def rotate_webhook_integration_secret( - name_or_id: str, secret: str | None -) -> None: - """Rotate a webhook integration signing secret. +def rotate_webhook_secret(name_or_id: str, secret: str | None) -> None: + """Rotate a webhook signing secret. Args: - name_or_id: The integration name or ID. + name_or_id: The webhook name or ID. secret: An optional direct replacement secret. """ try: - result = Client().rotate_webhook_integration_secret( + result = Client().rotate_webhook_secret( name_id_or_prefix=name_or_id, secret=secret ) except IllegalOperationError as error: @@ -201,19 +197,19 @@ def rotate_webhook_integration_secret( cli_utils.declare(f"Signing secret: {result.secret.get_secret_value()}") -@webhook_integration.command("delete") +@webhook.command("delete") @click.argument("name_or_id", type=str) @click.option("--yes", "confirmed", is_flag=True) -def delete_webhook_integration(name_or_id: str, confirmed: bool) -> None: - """Delete a webhook integration. +def delete_webhook(name_or_id: str, confirmed: bool) -> None: + """Delete a webhook. Args: - name_or_id: The integration name or ID. + name_or_id: The webhook name or ID. confirmed: Whether to skip the confirmation prompt. """ if not confirmed and not cli_utils.confirmation( - f"Delete webhook integration `{name_or_id}`?" + f"Delete webhook `{name_or_id}`?" ): return - Client().delete_webhook_integration(name_or_id) - cli_utils.declare(f"Deleted webhook integration `{name_or_id}`.") + Client().delete_webhook(name_or_id) + cli_utils.declare(f"Deleted webhook `{name_or_id}`.") diff --git a/src/zenml/client.py b/src/zenml/client.py index c58f49f3df9..4ae910db0f9 100644 --- a/src/zenml/client.py +++ b/src/zenml/client.py @@ -6840,27 +6840,27 @@ def delete_code_repository( ) self.zen_store.delete_code_repository(code_repository_id=repo.id) - # ------------------------- Webhook integrations ------------------------- + # ------------------------------ Webhooks ------------------------------ @_fail_for_sql_zen_store - def create_webhook_integration( + def create_webhook( self, name: str, webhook_type: WebhookType, active: bool = True, secret: str | None = None, ) -> WebhookIntegrationCreateResponse: - """Create a webhook integration in the active project. + """Create a webhook in the active project. Args: - name: The integration name. + name: The webhook name. webhook_type: The webhook provider type. active: Whether the integration accepts events immediately. secret: An optional direct signing secret or ZenML secret reference. Returns: - The created integration and any generated signing secret. + The created webhook and any generated signing secret. """ return self.zen_store.create_webhook_integration( WebhookIntegrationRequest( @@ -6873,27 +6873,27 @@ def create_webhook_integration( ) @_fail_for_sql_zen_store - def get_webhook_integration( + def get_webhook( self, name_id_or_prefix: str | UUID, allow_name_prefix_match: bool = True, project: str | UUID | None = None, hydrate: bool = True, ) -> WebhookIntegrationResponse: - """Get a webhook integration by name, ID, or prefix. + """Get a webhook by name, ID, or prefix. Args: - name_id_or_prefix: The integration name, ID, or ID prefix. + name_id_or_prefix: The webhook name, ID, or ID prefix. allow_name_prefix_match: Whether to allow name prefix matching. project: The project name or ID. hydrate: Whether to include intake statistics. Returns: - The webhook integration. + The webhook. """ return self._get_entity_by_id_or_name_or_prefix( get_method=self.zen_store.get_webhook_integration, - list_method=self.list_webhook_integrations, + list_method=self.list_webhooks, name_id_or_prefix=name_id_or_prefix, allow_name_prefix_match=allow_name_prefix_match, hydrate=hydrate, @@ -6901,7 +6901,7 @@ def get_webhook_integration( ) @_fail_for_sql_zen_store - def list_webhook_integrations( + def list_webhooks( self, sort_by: str = "created", page: int = PAGINATION_STARTING_PAGE, @@ -6917,17 +6917,17 @@ def list_webhook_integrations( active: bool | None = None, hydrate: bool = False, ) -> Page[WebhookIntegrationResponse]: - """List webhook integrations. + """List webhooks. Args: sort_by: The field used to sort results. page: The page index. size: The maximum page size. logical_operator: The operator used to combine filters. - id: Filter by integration ID. + id: Filter by webhook ID. created: Filter by creation time. updated: Filter by update time. - name: Filter by integration name. + name: Filter by webhook name. project: Filter by project name or ID. user: Filter by creating user. webhook_type: Filter by webhook provider type. @@ -6935,7 +6935,7 @@ def list_webhook_integrations( hydrate: Whether to include intake statistics. Returns: - A page of webhook integrations. + A page of webhooks. """ filter_model = WebhookIntegrationFilter( sort_by=sort_by, @@ -6956,7 +6956,7 @@ def list_webhook_integrations( ) @_fail_for_sql_zen_store - def update_webhook_integration( + def update_webhook( self, name_id_or_prefix: str | UUID, name: str | None = None, @@ -6964,17 +6964,17 @@ def update_webhook_integration( secret: str | None = None, project: str | UUID | None = None, ) -> WebhookIntegrationResponse: - """Update a webhook integration. + """Update a webhook. Args: - name_id_or_prefix: The integration name, ID, or ID prefix. - name: The new integration name. + name_id_or_prefix: The webhook name, ID, or ID prefix. + name: The new webhook name. active: The new active state. secret: A new direct signing secret or ZenML secret reference. project: The project name or ID. Returns: - The updated webhook integration. + The updated webhook. """ if is_valid_uuid(name_id_or_prefix): integration_id = ( @@ -6983,7 +6983,7 @@ def update_webhook_integration( else name_id_or_prefix ) else: - integration_id = self.get_webhook_integration( + integration_id = self.get_webhook( name_id_or_prefix=name_id_or_prefix, allow_name_prefix_match=False, project=project, @@ -6996,18 +6996,18 @@ def update_webhook_integration( ) @_fail_for_sql_zen_store - def delete_webhook_integration( + def delete_webhook( self, name_id_or_prefix: str | UUID, project: str | UUID | None = None, ) -> None: - """Delete a webhook integration. + """Delete a webhook. Args: - name_id_or_prefix: The integration name, ID, or ID prefix. + name_id_or_prefix: The webhook name, ID, or ID prefix. project: The project name or ID. """ - integration = self.get_webhook_integration( + integration = self.get_webhook( name_id_or_prefix=name_id_or_prefix, allow_name_prefix_match=False, project=project, @@ -7015,23 +7015,23 @@ def delete_webhook_integration( self.zen_store.delete_webhook_integration(integration.id) @_fail_for_sql_zen_store - def rotate_webhook_integration_secret( + def rotate_webhook_secret( self, name_id_or_prefix: str | UUID, secret: str | None = None, project: str | UUID | None = None, ) -> WebhookIntegrationSecretResponse: - """Rotate a webhook integration signing secret. + """Rotate a webhook signing secret. Args: - name_id_or_prefix: The integration name, ID, or ID prefix. + name_id_or_prefix: The webhook name, ID, or ID prefix. secret: An optional direct replacement secret. project: The project name or ID. Returns: The newly active signing secret. """ - integration = self.get_webhook_integration( + integration = self.get_webhook( name_id_or_prefix=name_id_or_prefix, allow_name_prefix_match=False, project=project, diff --git a/src/zenml/constants.py b/src/zenml/constants.py index f924556d716..83b8cccd037 100644 --- a/src/zenml/constants.py +++ b/src/zenml/constants.py @@ -544,7 +544,6 @@ def handle_float_env_var(var: str, default: float = 0.0) -> float: TAGS = "/tags" TAG_RESOURCES = "/tag_resources" TRIGGERS = "/triggers" -WEBHOOK_INTEGRATIONS = "/webhook_integrations" WEBHOOKS = "/webhooks" TRIGGER_SNAPSHOT_DISPATCH_STATE = "/dispatch_state" ONBOARDING_STATE = "/onboarding_state" diff --git a/src/zenml/models/v2/core/webhook_integration.py b/src/zenml/models/v2/core/webhook_integration.py index 8a288762eaa..0f2b885be63 100644 --- a/src/zenml/models/v2/core/webhook_integration.py +++ b/src/zenml/models/v2/core/webhook_integration.py @@ -167,7 +167,7 @@ class WebhookIntegrationFilter(ProjectScopedFilter): class WebhookIntegrationCreateResponse(BaseZenModel): """Creation result with a generated secret when applicable.""" - integration: WebhookIntegrationResponse + webhook: WebhookIntegrationResponse secret: PlainSerializedSecretStr | None = None diff --git a/src/zenml/zen_server/routers/webhook_integration_endpoints.py b/src/zenml/zen_server/routers/webhook_integration_endpoints.py index 800b68d47c4..a98107349ea 100644 --- a/src/zenml/zen_server/routers/webhook_integration_endpoints.py +++ b/src/zenml/zen_server/routers/webhook_integration_endpoints.py @@ -31,7 +31,6 @@ from zenml.constants import ( API, VERSION_1, - WEBHOOK_INTEGRATIONS, WEBHOOKS, ) from zenml.enums import WebhookType @@ -75,8 +74,8 @@ ) management_router = APIRouter( - prefix=API + VERSION_1 + WEBHOOK_INTEGRATIONS, - tags=["webhook_integrations"], + prefix=API + VERSION_1 + WEBHOOKS, + tags=["webhooks"], responses={401: error_response, 403: error_response}, ) @@ -106,14 +105,14 @@ def _verify_webhook_secret_reference_access( @management_router.post("") @async_fastapi_endpoint_wrapper -def create_webhook_integration( +def create_webhook( integration: WebhookIntegrationRequest, _: AuthContext = Security(authorize), ) -> WebhookIntegrationCreateResponse: - """Create a project-scoped webhook integration. + """Create a project-scoped webhook. Args: - integration: The webhook integration creation request. + integration: The webhook creation request. Returns: The created integration and any generated signing secret. @@ -125,21 +124,21 @@ def create_webhook_integration( @management_router.get("") @async_fastapi_endpoint_wrapper -def list_webhook_integrations( +def list_webhooks( filter_model: WebhookIntegrationFilter = Depends( make_dependable(WebhookIntegrationFilter) ), hydrate: bool = False, _: AuthContext = Security(authorize), ) -> Page[WebhookIntegrationResponse]: - """List webhook integrations. + """List webhooks. Args: - filter_model: The webhook integration filters. + filter_model: The webhook filters. hydrate: Whether to include intake statistics. Returns: - A page of webhook integrations. + A page of webhooks. """ return verify_permissions_and_list_entities( filter_model=filter_model, @@ -149,47 +148,47 @@ def list_webhook_integrations( ) -@management_router.get("/{integration_id}") +@management_router.get("/{webhook_id}") @async_fastapi_endpoint_wrapper -def get_webhook_integration( - integration_id: UUID, +def get_webhook( + webhook_id: UUID, hydrate: bool = True, _: AuthContext = Security(authorize), ) -> WebhookIntegrationResponse: - """Get a webhook integration. + """Get a webhook. Args: - integration_id: The webhook integration ID. + webhook_id: The webhook ID. hydrate: Whether to include intake statistics. Returns: - The webhook integration. + The webhook. """ return verify_permissions_and_get_entity( - id=integration_id, + id=webhook_id, get_method=zen_store().get_webhook_integration, hydrate=hydrate, ) -@management_router.put("/{integration_id}") +@management_router.put("/{webhook_id}") @async_fastapi_endpoint_wrapper -def update_webhook_integration( - integration_id: UUID, +def update_webhook( + webhook_id: UUID, update: WebhookIntegrationUpdate, _: AuthContext = Security(authorize), ) -> WebhookIntegrationResponse: - """Update a webhook integration. + """Update a webhook. Args: - integration_id: The webhook integration ID. - update: The webhook integration update. + webhook_id: The webhook ID. + update: The webhook update. Returns: - The updated webhook integration. + The updated webhook. """ integration = zen_store().get_webhook_integration( - integration_id, hydrate=False + webhook_id, hydrate=False ) verify_permission_for_model(model=integration, action=Action.UPDATE) _verify_webhook_secret_reference_access(update.secret) @@ -200,62 +199,62 @@ def update_webhook_integration( return dehydrate_response_model(updated_integration) -@management_router.delete("/{integration_id}") +@management_router.delete("/{webhook_id}") @async_fastapi_endpoint_wrapper -def delete_webhook_integration( - integration_id: UUID, +def delete_webhook( + webhook_id: UUID, _: AuthContext = Security(authorize), ) -> None: - """Delete a webhook integration and its signing secret. + """Delete a webhook and its signing secret. Args: - integration_id: The webhook integration ID. + webhook_id: The webhook ID. """ verify_permissions_and_delete_entity( - id=integration_id, + id=webhook_id, get_method=zen_store().get_webhook_integration, delete_method=zen_store().delete_webhook_integration, ) -@management_router.put("/{integration_id}/secret") +@management_router.put("/{webhook_id}/secret") @async_fastapi_endpoint_wrapper -def rotate_webhook_integration_secret( - integration_id: UUID, +def rotate_webhook_secret( + webhook_id: UUID, request: WebhookIntegrationRotateSecretRequest, _: AuthContext = Security(authorize), ) -> WebhookIntegrationSecretResponse: - """Rotate a webhook integration signing secret. + """Rotate a webhook signing secret. Args: - integration_id: The webhook integration ID. + webhook_id: The webhook ID. request: The secret rotation request. Returns: The newly active signing secret. """ - integration = zen_store().get_webhook_integration(integration_id) + integration = zen_store().get_webhook_integration(webhook_id) verify_permission_for_model(model=integration, action=Action.UPDATE) return zen_store().rotate_webhook_integration_secret( - integration_id=integration_id, request=request + integration_id=webhook_id, request=request ) @intake_router.post( - "/{webhook_type}/{integration_id}/events", + "/{webhook_type}/{webhook_id}/events", status_code=status.HTTP_202_ACCEPTED, ) @async_handle_endpoint_errors async def receive_webhook_event( webhook_type: WebhookType, - integration_id: UUID, + webhook_id: UUID, request: Request, ) -> Response: """Authenticate and accept a provider webhook event. Args: webhook_type: The provider type from the public endpoint path. - integration_id: The webhook integration ID. + webhook_id: The webhook ID. request: The raw HTTP request. Returns: @@ -265,7 +264,7 @@ async def receive_webhook_event( return await run_in_threadpool( _receive_webhook_event, webhook_type=webhook_type, - integration_id=integration_id, + integration_id=webhook_id, body=body, headers=request.headers, ) @@ -281,7 +280,7 @@ def _receive_webhook_event( Args: webhook_type: The provider type from the public endpoint path. - integration_id: The webhook integration ID. + integration_id: The webhook ID. body: The raw request body. headers: The request headers. diff --git a/src/zenml/zen_stores/rest_zen_store.py b/src/zenml/zen_stores/rest_zen_store.py index 50849d0032d..1b715c951db 100644 --- a/src/zenml/zen_stores/rest_zen_store.py +++ b/src/zenml/zen_stores/rest_zen_store.py @@ -126,7 +126,7 @@ TRIGGERS, USERS, VERSION_1, - WEBHOOK_INTEGRATIONS, + WEBHOOKS, ZENML_PRO_API_KEY_PREFIX, ) from zenml.enums import ( @@ -2598,7 +2598,7 @@ def create_webhook_integration( Returns: The created integration and any generated signing secret. """ - response = self.post(WEBHOOK_INTEGRATIONS, body=integration) + response = self.post(WEBHOOKS, body=integration) return WebhookIntegrationCreateResponse.model_validate(response) def get_webhook_integration( @@ -2614,7 +2614,7 @@ def get_webhook_integration( The webhook integration. """ response = self.get( - f"{WEBHOOK_INTEGRATIONS}/{integration_id}", + f"{WEBHOOKS}/{integration_id}", params={"hydrate": hydrate}, ) return WebhookIntegrationResponse.model_validate(response) @@ -2634,7 +2634,7 @@ def list_webhook_integrations( A page of webhook integrations. """ response = self.get( - WEBHOOK_INTEGRATIONS, + WEBHOOKS, params={ "hydrate": hydrate, **filter_model.model_dump(exclude_none=True), @@ -2656,9 +2656,7 @@ def update_webhook_integration( Returns: The updated webhook integration. """ - response = self.put( - f"{WEBHOOK_INTEGRATIONS}/{integration_id}", body=update - ) + response = self.put(f"{WEBHOOKS}/{integration_id}", body=update) return WebhookIntegrationResponse.model_validate(response) def delete_webhook_integration(self, integration_id: UUID) -> None: @@ -2667,7 +2665,7 @@ def delete_webhook_integration(self, integration_id: UUID) -> None: Args: integration_id: The webhook integration ID. """ - self.delete(f"{WEBHOOK_INTEGRATIONS}/{integration_id}") + self.delete(f"{WEBHOOKS}/{integration_id}") def rotate_webhook_integration_secret( self, @@ -2684,7 +2682,7 @@ def rotate_webhook_integration_secret( The newly active signing secret. """ response = self.put( - f"{WEBHOOK_INTEGRATIONS}/{integration_id}/secret", body=request + f"{WEBHOOKS}/{integration_id}/secret", body=request ) return WebhookIntegrationSecretResponse.model_validate(response) diff --git a/src/zenml/zen_stores/sql_zen_store.py b/src/zenml/zen_stores/sql_zen_store.py index 6118bc43b7c..ab2b26f5ecd 100644 --- a/src/zenml/zen_stores/sql_zen_store.py +++ b/src/zenml/zen_stores/sql_zen_store.py @@ -8238,7 +8238,7 @@ def create_webhook_integration( ) raise return WebhookIntegrationCreateResponse( - integration=schema.to_model( + webhook=schema.to_model( include_metadata=True, include_resources=True ), secret=( @@ -8393,7 +8393,7 @@ def rotate_webhook_integration_secret( ): raise IllegalOperationError( "Secret references cannot be configured through secret " - "rotation. Use `zenml webhook-integration update " + "rotation. Use `zenml webhook update " f"{schema.name} --secret='" f"{request.secret.get_secret_value()}'` instead." ) diff --git a/tests/integration/functional/webhooks/test_cli.py b/tests/integration/functional/webhooks/test_cli.py index 4ee3e6b5f53..7292289c543 100644 --- a/tests/integration/functional/webhooks/test_cli.py +++ b/tests/integration/functional/webhooks/test_cli.py @@ -29,28 +29,28 @@ def clean_client(clean_project: Client) -> Client: """Return the active client when it is connected to a server.""" if isinstance(clean_project.zen_store, SqlZenStore): - pytest.skip("Webhook integrations require a REST store.") + pytest.skip("Webhooks require a REST store.") return clean_project -webhook_integration_command = cli.commands["webhook-integration"] -create_command = webhook_integration_command.commands["create"] -describe_command = webhook_integration_command.commands["describe"] -list_command = webhook_integration_command.commands["list"] -update_command = webhook_integration_command.commands["update"] -rotate_secret_command = webhook_integration_command.commands["rotate-secret"] -delete_command = webhook_integration_command.commands["delete"] +webhook_command = cli.commands["webhook"] +create_command = webhook_command.commands["create"] +describe_command = webhook_command.commands["describe"] +list_command = webhook_command.commands["list"] +update_command = webhook_command.commands["update"] +rotate_secret_command = webhook_command.commands["rotate-secret"] +delete_command = webhook_command.commands["delete"] def _delete_if_exists(name_or_id: str) -> None: client = Client() try: - client.delete_webhook_integration(name_or_id) + client.delete_webhook(name_or_id) except KeyError: pass -def test_webhook_integration_cli_lifecycle(clean_client): +def test_webhook_cli_lifecycle(clean_client): runner = cli_runner() name = sample_name("webhook-cli") updated_name = sample_name("webhook-cli-updated") @@ -64,7 +64,7 @@ def test_webhook_integration_cli_lifecycle(clean_client): assert result.exit_code == 0, result.output assert "Signing secret:" in result.output - integration = clean_client.get_webhook_integration(name) + integration = clean_client.get_webhook(name) assert integration.webhook_type == WebhookType.CUSTOM assert integration.active is True @@ -89,7 +89,7 @@ def test_webhook_integration_cli_lifecycle(clean_client): ) assert result.exit_code == 0, result.output - updated = clean_client.get_webhook_integration(updated_name) + updated = clean_client.get_webhook(updated_name) assert updated.id == integration.id assert updated.active is False @@ -105,13 +105,13 @@ def test_webhook_integration_cli_lifecycle(clean_client): assert result.exit_code == 0, result.output with pytest.raises(KeyError): - clean_client.get_webhook_integration(updated.id) + clean_client.get_webhook(updated.id) finally: _delete_if_exists(updated_name) _delete_if_exists(name) -def test_webhook_integration_cli_does_not_echo_user_supplied_secret( +def test_webhook_cli_does_not_echo_user_supplied_secret( clean_client, ): runner = cli_runner() @@ -133,14 +133,14 @@ def test_webhook_integration_cli_does_not_echo_user_supplied_secret( assert "user-supplied-secret" not in result.output assert "Signing secret:" not in result.output - integration = clean_client.get_webhook_integration(name) + integration = clean_client.get_webhook(name) assert integration.webhook_type == WebhookType.GITHUB assert "secret" not in integration.model_dump() finally: _delete_if_exists(name) -def test_webhook_integration_cli_rejects_rotation_for_secret_reference( +def test_webhook_cli_rejects_rotation_for_secret_reference( clean_client, ): """The CLI guides referenced credentials to the secret update command.""" diff --git a/tests/integration/functional/webhooks/test_client.py b/tests/integration/functional/webhooks/test_client.py index 976a3fcd24a..9246485902c 100644 --- a/tests/integration/functional/webhooks/test_client.py +++ b/tests/integration/functional/webhooks/test_client.py @@ -24,20 +24,20 @@ def clean_client(clean_project: Client) -> Client: """Return the active client when it is connected to a server.""" if isinstance(clean_project.zen_store, SqlZenStore): - pytest.skip("Webhook integrations require a REST store.") + pytest.skip("Webhooks require a REST store.") return clean_project -def test_client_webhook_integration_lifecycle(clean_client): +def test_client_webhook_lifecycle(clean_client): name = sample_name("webhook-client") - result = clean_client.create_webhook_integration( + result = clean_client.create_webhook( name=name, webhook_type=WebhookType.CUSTOM, ) assert result.secret is not None - integration = result.integration + integration = result.webhook assert integration.name == name assert integration.webhook_type == WebhookType.CUSTOM assert integration.active is True @@ -47,25 +47,23 @@ def test_client_webhook_integration_lifecycle(clean_client): ) assert integration.stats.received_count == 0 - by_id = clean_client.get_webhook_integration(integration.id) - by_name = clean_client.get_webhook_integration(name) + by_id = clean_client.get_webhook(integration.id) + by_name = clean_client.get_webhook(name) assert by_id.id == integration.id assert by_name.id == integration.id assert "secret" not in by_id.model_dump() - listed_by_type = clean_client.list_webhook_integrations( + listed_by_type = clean_client.list_webhooks( webhook_type=WebhookType.CUSTOM ) - listed_by_active_state = clean_client.list_webhook_integrations( - active=True - ) + listed_by_active_state = clean_client.list_webhooks(active=True) assert integration.id in {item.id for item in listed_by_type.items} assert integration.id in {item.id for item in listed_by_active_state.items} updated_name = sample_name("webhook-client-updated") - updated = clean_client.update_webhook_integration( + updated = clean_client.update_webhook( name_id_or_prefix=name, name=updated_name, active=False, @@ -75,29 +73,27 @@ def test_client_webhook_integration_lifecycle(clean_client): assert updated.name == updated_name assert updated.active is False - inactive_integrations = clean_client.list_webhook_integrations( - active=False - ) + inactive_integrations = clean_client.list_webhooks(active=False) assert integration.id in {item.id for item in inactive_integrations.items} - rotated = clean_client.rotate_webhook_integration_secret( + rotated = clean_client.rotate_webhook_secret( name_id_or_prefix=updated_name, secret="replacement-secret", ) assert rotated.secret.get_secret_value() == "replacement-secret" - clean_client.delete_webhook_integration(updated_name) + clean_client.delete_webhook(updated_name) with pytest.raises(KeyError): - clean_client.get_webhook_integration(integration.id) + clean_client.get_webhook(integration.id) def test_client_does_not_echo_user_supplied_webhook_secret(clean_client): name = sample_name("webhook-client-secret") - result = clean_client.create_webhook_integration( + result = clean_client.create_webhook( name=name, webhook_type=WebhookType.GITHUB, secret="user-supplied-secret", @@ -106,33 +102,31 @@ def test_client_does_not_echo_user_supplied_webhook_secret(clean_client): try: assert result.secret is None - integration = clean_client.get_webhook_integration( - result.integration.id - ) + integration = clean_client.get_webhook(result.webhook.id) assert "secret" not in integration.model_dump() assert integration.webhook_type == WebhookType.GITHUB finally: - clean_client.delete_webhook_integration(result.integration.id) + clean_client.delete_webhook(result.webhook.id) -def test_client_update_webhook_integration_by_name_and_id( +def test_client_update_webhook_by_name_and_id( clean_client, ) -> None: """Webhook integrations can be updated by name and ID.""" name = sample_name("webhook-client-update") - result = clean_client.create_webhook_integration( + result = clean_client.create_webhook( name=name, webhook_type=WebhookType.CUSTOM, ) - integration_id = result.integration.id + integration_id = result.webhook.id try: - updated_by_name = clean_client.update_webhook_integration( + updated_by_name = clean_client.update_webhook( name, active=False, ) - updated_by_id = clean_client.update_webhook_integration( + updated_by_id = clean_client.update_webhook( integration_id, active=True, ) @@ -142,7 +136,7 @@ def test_client_update_webhook_integration_by_name_and_id( assert updated_by_id.id == integration_id assert updated_by_id.active is True finally: - clean_client.delete_webhook_integration(integration_id) + clean_client.delete_webhook(integration_id) def test_client_rejects_rotation_for_referenced_webhook_secret(clean_client): @@ -150,38 +144,34 @@ def test_client_rejects_rotation_for_referenced_webhook_secret(clean_client): integration_name = sample_name("webhook-client-reference") secret_name = sample_name("webhook-signing-secret") clean_client.create_secret(secret_name, values={"key": "initial-secret"}) - result = clean_client.create_webhook_integration( + result = clean_client.create_webhook( name=integration_name, webhook_type=WebhookType.CUSTOM, secret="managed-secret", ) try: - clean_client.update_webhook_integration( + clean_client.update_webhook( integration_name, secret=f"{{{{{secret_name}.key}}}}", ) with pytest.raises(IllegalOperationError, match="zenml secret update"): - clean_client.rotate_webhook_integration_secret(integration_name) + clean_client.rotate_webhook_secret(integration_name) - clean_client.update_webhook_integration( + clean_client.update_webhook( integration_name, secret="managed-secret-again", ) - with pytest.raises( - IllegalOperationError, match="webhook-integration update" - ): - clean_client.rotate_webhook_integration_secret( + with pytest.raises(IllegalOperationError, match="webhook update"): + clean_client.rotate_webhook_secret( integration_name, secret=f"{{{{{secret_name}.key}}}}", ) - rotated = clean_client.rotate_webhook_integration_secret( - integration_name - ) + rotated = clean_client.rotate_webhook_secret(integration_name) assert rotated.secret.get_secret_value() finally: - clean_client.delete_webhook_integration(result.integration.id) + clean_client.delete_webhook(result.webhook.id) clean_client.delete_secret(secret_name) diff --git a/tests/integration/functional/webhooks/test_events_endpoint.py b/tests/integration/functional/webhooks/test_events_endpoint.py index 5da395ba265..fefd8efb042 100644 --- a/tests/integration/functional/webhooks/test_events_endpoint.py +++ b/tests/integration/functional/webhooks/test_events_endpoint.py @@ -61,19 +61,19 @@ def create( active: bool = True, secret: str | None = None, ) -> WebhookIntegrationCreateResponse: - result = clean_project.create_webhook_integration( + result = clean_project.create_webhook( name=sample_name(name_prefix), webhook_type=WebhookType.CUSTOM, active=active, secret=secret, ) - integration_ids.append(result.integration.id) + integration_ids.append(result.webhook.id) return result yield create for integration_id in integration_ids: - clean_project.delete_webhook_integration(integration_id) + clean_project.delete_webhook(integration_id) def test_webhook_intake_accepts_valid_custom_delivery( @@ -81,7 +81,7 @@ def test_webhook_intake_accepts_valid_custom_delivery( ): store = _require_rest_store(clean_project) result = webhook_integration_factory("webhook-intake-valid") - integration = result.integration + integration = result.webhook assert result.secret is not None secret = result.secret.get_secret_value() body = b'{"pipeline":"training"}' @@ -100,7 +100,7 @@ def test_webhook_intake_accepts_valid_custom_delivery( assert response.status_code == 202 assert response.content == b"" - updated = clean_project.get_webhook_integration(integration.id) + updated = clean_project.get_webhook(integration.id) assert updated.stats.received_count == 1 assert updated.stats.accepted_count == 1 assert updated.stats.auth_failed_count == 0 @@ -116,12 +116,12 @@ def test_webhook_intake_resolves_updated_secret_reference( store = _require_rest_store(clean_project) secret_name = sample_name("webhook-intake-reference") clean_project.create_secret(secret_name, values={"key": "initial-secret"}) - result = clean_project.create_webhook_integration( + result = clean_project.create_webhook( name=sample_name("webhook-intake-reference-integration"), webhook_type=WebhookType.CUSTOM, secret=f"{{{{{secret_name}.key}}}}", ) - integration = result.integration + integration = result.webhook body = b'{"pipeline":"training"}' try: @@ -151,7 +151,7 @@ def test_webhook_intake_resolves_updated_secret_reference( assert response.status_code == 202 finally: - clean_project.delete_webhook_integration(integration.id) + clean_project.delete_webhook(integration.id) clean_project.delete_secret(secret_name) @@ -187,7 +187,7 @@ def test_webhook_intake_failure_scenarios( f"webhook-intake-{scenario}", active=scenario != "inactive", ) - integration = result.integration + integration = result.webhook assert result.secret is not None secret = result.secret.get_secret_value() body = b"not-json" if scenario == "invalid-payload" else b'{"ok":true}' @@ -210,7 +210,7 @@ def test_webhook_intake_failure_scenarios( ) assert response.status_code == expected_status - updated = clean_project.get_webhook_integration(integration.id) + updated = clean_project.get_webhook(integration.id) assert ( updated.stats.received_count, updated.stats.accepted_count, diff --git a/tests/integration/functional/webhooks/test_zen_store.py b/tests/integration/functional/webhooks/test_zen_store.py index a71177e8ede..162953f1291 100644 --- a/tests/integration/functional/webhooks/test_zen_store.py +++ b/tests/integration/functional/webhooks/test_zen_store.py @@ -24,29 +24,29 @@ from zenml.zen_stores.sql_zen_store import SqlZenStore -def test_client_webhook_integration_methods_require_rest_store( +def test_client_webhook_methods_require_rest_store( clean_client, ) -> None: - """Public webhook integration client methods reject local SQL stores.""" + """Public webhook client methods reject local SQL stores.""" if not isinstance(clean_client.zen_store, SqlZenStore): pytest.skip("Local SQL store behavior is required for this test.") error = "This method is not allowed when not connected" with pytest.raises(TypeError, match=error): - clean_client.create_webhook_integration( + clean_client.create_webhook( name="webhook", webhook_type=WebhookType.CUSTOM, ) with pytest.raises(TypeError, match=error): - clean_client.get_webhook_integration("webhook") + clean_client.get_webhook("webhook") with pytest.raises(TypeError, match=error): - clean_client.list_webhook_integrations() + clean_client.list_webhooks() with pytest.raises(TypeError, match=error): - clean_client.update_webhook_integration("webhook", active=False) + clean_client.update_webhook("webhook", active=False) with pytest.raises(TypeError, match=error): - clean_client.delete_webhook_integration("webhook") + clean_client.delete_webhook("webhook") with pytest.raises(TypeError, match=error): - clean_client.rotate_webhook_integration_secret("webhook") + clean_client.rotate_webhook_secret("webhook") def test_zen_store_webhook_integration_lifecycle(clean_client): @@ -62,7 +62,7 @@ def test_zen_store_webhook_integration_lifecycle(clean_client): ) ) - integration = result.integration + integration = result.webhook assert result.secret is not None assert integration.name == name @@ -144,7 +144,7 @@ def test_sql_store_resolves_webhook_secret_references_lazily(clean_client): try: assert ( - store.get_webhook_integration_secret(result.integration.id) + store.get_webhook_integration_secret(result.webhook.id) == "initial-secret" ) @@ -153,9 +153,9 @@ def test_sql_store_resolves_webhook_secret_references_lazily(clean_client): ) assert ( - store.get_webhook_integration_secret(result.integration.id) + store.get_webhook_integration_secret(result.webhook.id) == "rotated-secret" ) finally: - store.delete_webhook_integration(result.integration.id) + store.delete_webhook_integration(result.webhook.id) clean_client.delete_secret(secret_name) diff --git a/tests/unit/zen_server/endpoints/test_webhook_integration_endpoints.py b/tests/unit/zen_server/endpoints/test_webhook_integration_endpoints.py index 25212af1ec1..a5610e3cac4 100644 --- a/tests/unit/zen_server/endpoints/test_webhook_integration_endpoints.py +++ b/tests/unit/zen_server/endpoints/test_webhook_integration_endpoints.py @@ -18,12 +18,21 @@ from fastapi import HTTPException, status from pydantic import SecretStr +from zenml.constants import API, VERSION_1, WEBHOOKS from zenml.enums import WebhookType from zenml.models import WebhookIntegrationUpdate from zenml.webhooks import WebhookAuthenticationError, WebhookPayloadError from zenml.zen_server.routers import webhook_integration_endpoints as endpoints +def test_webhook_routers_use_public_webhook_prefix() -> None: + """Management and intake endpoints share the public webhook prefix.""" + expected_prefix = API + VERSION_1 + WEBHOOKS + + assert endpoints.management_router.prefix == expected_prefix + assert endpoints.intake_router.prefix == expected_prefix + + class _Store: def __init__(self, integration: SimpleNamespace | None) -> None: self.integration = integration @@ -142,8 +151,8 @@ def update_webhook_integration(self, integration_id, update): lambda model: model, ) - result = endpoints.update_webhook_integration.__wrapped__( - integration_id=integration_id, + result = endpoints.update_webhook.__wrapped__( + webhook_id=integration_id, update=WebhookIntegrationUpdate(secret="{{webhook.key}}"), _=SimpleNamespace(), ) From a806a66d76e7f511618265874992b218edcb849a Mon Sep 17 00:00:00 2001 From: Iason Andriopoulos Date: Wed, 15 Jul 2026 15:53:52 +0300 Subject: [PATCH 14/18] Secret/Webhook deletion propagation --- src/zenml/cli/webhook_integration.py | 21 +------ .../routers/webhook_integration_endpoints.py | 3 +- .../7c0d9e4a1b2f_add_webhook_integrations.py | 2 +- .../schemas/webhook_integration_schemas.py | 2 +- src/zenml/zen_stores/sql_zen_store.py | 21 ++++++- .../functional/webhooks/test_zen_store.py | 61 +++++++++++++++++++ 6 files changed, 85 insertions(+), 25 deletions(-) diff --git a/src/zenml/cli/webhook_integration.py b/src/zenml/cli/webhook_integration.py index b9bf8856138..c39d1e2a323 100644 --- a/src/zenml/cli/webhook_integration.py +++ b/src/zenml/cli/webhook_integration.py @@ -24,7 +24,7 @@ from zenml.console import console from zenml.enums import CliCategories, WebhookType from zenml.exceptions import IllegalOperationError -from zenml.models import WebhookIntegrationFilter, WebhookIntegrationResponse +from zenml.models import WebhookIntegrationFilter @cli.group("webhook", cls=TagGroup, tag=CliCategories.MANAGEMENT_TOOLS) @@ -93,26 +93,10 @@ def describe_webhook(name_or_id: str) -> None: cli_utils.declare(f"Endpoint path: {integration.endpoint_path}") -def _format_webhook_integration_row( - integration: WebhookIntegrationResponse, - _output_format: OutputFormat, -) -> dict[str, Any]: - """Add the project name to a webhook integration list row. - - Args: - integration: The webhook integration to format. - _output_format: The requested output format. - - Returns: - Additional row values for the webhook integration. - """ - return {"project": integration.project.name} - - @webhook.command("list") @list_options( WebhookIntegrationFilter, - default_columns=["id", "name", "webhook_type", "active", "project"], + default_columns=["id", "name", "webhook_type", "active"], ) def list_webhooks( columns: str, output_format: OutputFormat, **kwargs: Any @@ -130,7 +114,6 @@ def list_webhooks( integrations, columns, output_format, - row_formatter=_format_webhook_integration_row, empty_message="No webhooks found for this filter.", ) diff --git a/src/zenml/zen_server/routers/webhook_integration_endpoints.py b/src/zenml/zen_server/routers/webhook_integration_endpoints.py index a98107349ea..02629b71dea 100644 --- a/src/zenml/zen_server/routers/webhook_integration_endpoints.py +++ b/src/zenml/zen_server/routers/webhook_integration_endpoints.py @@ -119,7 +119,8 @@ def create_webhook( """ verify_permission_for_model(model=integration, action=Action.CREATE) _verify_webhook_secret_reference_access(integration.secret) - return zen_store().create_webhook_integration(integration) + result = zen_store().create_webhook_integration(integration) + return dehydrate_response_model(result) @management_router.get("") diff --git a/src/zenml/zen_stores/migrations/versions/7c0d9e4a1b2f_add_webhook_integrations.py b/src/zenml/zen_stores/migrations/versions/7c0d9e4a1b2f_add_webhook_integrations.py index e5a9d567603..8f204ae6338 100644 --- a/src/zenml/zen_stores/migrations/versions/7c0d9e4a1b2f_add_webhook_integrations.py +++ b/src/zenml/zen_stores/migrations/versions/7c0d9e4a1b2f_add_webhook_integrations.py @@ -65,7 +65,7 @@ def upgrade() -> None: ["secret_id"], ["secret.id"], name="fk_webhook_integration_secret_id_secret", - ondelete="CASCADE", + ondelete="RESTRICT", ), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint( diff --git a/src/zenml/zen_stores/schemas/webhook_integration_schemas.py b/src/zenml/zen_stores/schemas/webhook_integration_schemas.py index ac8fd6672c9..bf251f7b67e 100644 --- a/src/zenml/zen_stores/schemas/webhook_integration_schemas.py +++ b/src/zenml/zen_stores/schemas/webhook_integration_schemas.py @@ -81,7 +81,7 @@ class WebhookIntegrationSchema(NamedSchema, table=True): target=SecretSchema.__tablename__, source_column="secret_id", target_column="id", - ondelete="CASCADE", + ondelete="RESTRICT", nullable=False, ) webhook_type: str diff --git a/src/zenml/zen_stores/sql_zen_store.py b/src/zenml/zen_stores/sql_zen_store.py index ab2b26f5ecd..0e778706456 100644 --- a/src/zenml/zen_stores/sql_zen_store.py +++ b/src/zenml/zen_stores/sql_zen_store.py @@ -8347,9 +8347,10 @@ def delete_webhook_integration(self, integration_id: UUID) -> None: schema_class=WebhookIntegrationSchema, session=session, ) - self._delete_secret_schema( - secret_id=schema.secret_id, session=session - ) + secret_id = schema.secret_id + session.delete(schema) + session.commit() + self._delete_secret_schema(secret_id=secret_id, session=session) def rotate_webhook_integration_secret( self, @@ -10002,7 +10003,21 @@ def _delete_secret_schema(self, secret_id: UUID, session: Session) -> None: Args: secret_id: The ID of the secret to delete. session: The session to use. + + Raises: + IllegalOperationError: If the secret is owned by a webhook. """ + webhook_id = session.exec( + select(WebhookIntegrationSchema.id).where( + WebhookIntegrationSchema.secret_id == secret_id + ) + ).first() + if webhook_id is not None: + raise IllegalOperationError( + f"Cannot delete secret {secret_id}: it is owned by webhook " + f"{webhook_id}. Delete the webhook instead." + ) + # Delete the secret values in the configured secrets store try: self._delete_secret_values(secret_id=secret_id) diff --git a/tests/integration/functional/webhooks/test_zen_store.py b/tests/integration/functional/webhooks/test_zen_store.py index 162953f1291..841321ece8c 100644 --- a/tests/integration/functional/webhooks/test_zen_store.py +++ b/tests/integration/functional/webhooks/test_zen_store.py @@ -12,15 +12,22 @@ # or implied. See the License for the specific language governing # permissions and limitations under the License. import pytest +from sqlalchemy.exc import IntegrityError +from sqlmodel import Session, select from tests.integration.functional.utils import sample_name from zenml.enums import WebhookType +from zenml.exceptions import IllegalOperationError from zenml.models import ( WebhookIntegrationFilter, WebhookIntegrationRequest, WebhookIntegrationRotateSecretRequest, WebhookIntegrationUpdate, ) +from zenml.zen_stores.schemas.secret_schemas import SecretSchema +from zenml.zen_stores.schemas.webhook_integration_schemas import ( + WebhookIntegrationSchema, +) from zenml.zen_stores.sql_zen_store import SqlZenStore @@ -125,6 +132,60 @@ def test_zen_store_webhook_integration_lifecycle(clean_client): store.get_webhook_integration(integration.id) +def test_sql_store_protects_webhook_owned_secret(clean_client) -> None: + """Webhook-owned secrets require explicit webhook deletion.""" + store = clean_client.zen_store + if not isinstance(store, SqlZenStore): + pytest.skip("Local SQL store behavior is required for this test.") + + result = store.create_webhook_integration( + WebhookIntegrationRequest( + project=clean_client.active_project.id, + name=sample_name("webhook-owned-secret"), + webhook_type=WebhookType.CUSTOM, + secret="owned-secret", + ) + ) + webhook_id = result.webhook.id + + try: + with Session(store.engine) as session: + schema = session.exec( + select(WebhookIntegrationSchema).where( + WebhookIntegrationSchema.id == webhook_id + ) + ).one() + secret_id = schema.secret_id + + secret = session.get(SecretSchema, secret_id) + assert secret is not None + session.delete(secret) + with pytest.raises(IntegrityError): + session.commit() + session.rollback() + + with pytest.raises(IllegalOperationError): + store._delete_secret_schema( + secret_id=secret_id, session=session + ) + + assert store.get_webhook_integration_secret(webhook_id) == ( + "owned-secret" + ) + assert store.get_webhook_integration(webhook_id).id == webhook_id + + store.delete_webhook_integration(webhook_id) + + with Session(store.engine) as session: + assert session.get(WebhookIntegrationSchema, webhook_id) is None + assert session.get(SecretSchema, secret_id) is None + finally: + try: + store.delete_webhook_integration(webhook_id) + except KeyError: + pass + + def test_sql_store_resolves_webhook_secret_references_lazily(clean_client): """Webhook secret references resolve their current value at intake time.""" store = clean_client.zen_store From 7e976a0c37e5ddc763823511e1e118c6dabb80f4 Mon Sep 17 00:00:00 2001 From: Iason Andriopoulos Date: Wed, 15 Jul 2026 17:24:54 +0300 Subject: [PATCH 15/18] Inherit intake auth exc from std server errors --- src/zenml/webhooks/adapters.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/zenml/webhooks/adapters.py b/src/zenml/webhooks/adapters.py index 9d07f6a6fcd..d06c87b630a 100644 --- a/src/zenml/webhooks/adapters.py +++ b/src/zenml/webhooks/adapters.py @@ -23,9 +23,10 @@ from pydantic import BaseModel from zenml.enums import WebhookType +from zenml.exceptions import CredentialsNotValid -class WebhookAuthenticationError(ValueError): +class WebhookAuthenticationError(CredentialsNotValid): """Raised when a webhook request cannot be authenticated.""" From 0a30e4c5b60a534913c8dba55582f4033a536b12 Mon Sep 17 00:00:00 2001 From: Iason Andriopoulos Date: Thu, 16 Jul 2026 11:36:34 +0300 Subject: [PATCH 16/18] Performance improvements - Flat stats table - single query atomic update (intake endpoint imp) - Drop support for secret ref (reduce secrets store calls) - Lightweight webhook resolution (intake endpoint imp) --- src/zenml/cli/webhook_integration.py | 16 +- src/zenml/client.py | 5 +- .../models/v2/core/webhook_integration.py | 9 +- .../routers/webhook_integration_endpoints.py | 37 +--- .../7c0d9e4a1b2f_add_webhook_integrations.py | 41 +++- src/zenml/zen_stores/schemas/__init__.py | 2 + .../schemas/webhook_integration_schemas.py | 93 ++++++-- src/zenml/zen_stores/sql_zen_store.py | 209 +++++++----------- .../functional/webhooks/test_cli.py | 43 ---- .../functional/webhooks/test_client.py | 39 ---- .../webhooks/test_events_endpoint.py | 46 ---- .../functional/webhooks/test_zen_store.py | 102 +++++---- .../test_webhook_integration_endpoints.py | 92 +------- .../test_webhook_integration_schemas.py | 38 ++-- 14 files changed, 292 insertions(+), 480 deletions(-) diff --git a/src/zenml/cli/webhook_integration.py b/src/zenml/cli/webhook_integration.py index c39d1e2a323..5c2fee441ab 100644 --- a/src/zenml/cli/webhook_integration.py +++ b/src/zenml/cli/webhook_integration.py @@ -23,7 +23,6 @@ from zenml.client import Client from zenml.console import console from zenml.enums import CliCategories, WebhookType -from zenml.exceptions import IllegalOperationError from zenml.models import WebhookIntegrationFilter @@ -44,7 +43,7 @@ def webhook() -> None: "--secret", type=str, default=None, - help="Set a direct signing secret or ZenML secret reference.", + help="Set a signing secret.", ) @click.option("--inactive", is_flag=True, default=False) def create_webhook( @@ -128,7 +127,7 @@ def list_webhooks( "--secret", type=str, default=None, - help="Set a direct signing secret or ZenML secret reference.", + help="Set a replacement signing secret.", ) def update_webhook( name_or_id: str, @@ -142,7 +141,7 @@ def update_webhook( name_or_id: The webhook name or ID. new_name: The new webhook name. active: The new active state. - secret: A direct signing secret or ZenML secret reference. + secret: A replacement signing secret. """ integration = Client().update_webhook( name_id_or_prefix=name_or_id, @@ -171,12 +170,9 @@ def rotate_webhook_secret(name_or_id: str, secret: str | None) -> None: name_or_id: The webhook name or ID. secret: An optional direct replacement secret. """ - try: - result = Client().rotate_webhook_secret( - name_id_or_prefix=name_or_id, secret=secret - ) - except IllegalOperationError as error: - cli_utils.exception(error) + result = Client().rotate_webhook_secret( + name_id_or_prefix=name_or_id, secret=secret + ) cli_utils.declare(f"Signing secret: {result.secret.get_secret_value()}") diff --git a/src/zenml/client.py b/src/zenml/client.py index 4ae910db0f9..c3c8b2917be 100644 --- a/src/zenml/client.py +++ b/src/zenml/client.py @@ -6856,8 +6856,7 @@ def create_webhook( name: The webhook name. webhook_type: The webhook provider type. active: Whether the integration accepts events immediately. - secret: An optional direct signing secret or ZenML secret - reference. + secret: An optional signing secret. Returns: The created webhook and any generated signing secret. @@ -6970,7 +6969,7 @@ def update_webhook( name_id_or_prefix: The webhook name, ID, or ID prefix. name: The new webhook name. active: The new active state. - secret: A new direct signing secret or ZenML secret reference. + secret: A new signing secret. project: The project name or ID. Returns: diff --git a/src/zenml/models/v2/core/webhook_integration.py b/src/zenml/models/v2/core/webhook_integration.py index 0f2b885be63..90e9698a8f7 100644 --- a/src/zenml/models/v2/core/webhook_integration.py +++ b/src/zenml/models/v2/core/webhook_integration.py @@ -44,8 +44,8 @@ class WebhookIntegrationRequest(ProjectScopedRequest): active: bool = True secret: NonEmptyPlainSerializedSecretStr | None = Field( default=None, - description="Optional direct signing secret or ZenML secret " - "reference. A direct secret is generated when omitted.", + description="Optional signing secret. A secret is generated when " + "omitted.", ) @@ -56,7 +56,7 @@ class WebhookIntegrationUpdate(BaseUpdate): active: bool | None = None secret: NonEmptyPlainSerializedSecretStr | None = Field( default=None, - description="A direct signing secret or ZenML secret reference.", + description="A replacement signing secret.", ) @@ -176,8 +176,7 @@ class WebhookIntegrationRotateSecretRequest(BaseZenModel): secret: NonEmptyPlainSerializedSecretStr | None = Field( default=None, - description="Optional direct replacement secret. Secret references " - "must be configured through an integration update.", + description="Optional direct replacement secret.", ) diff --git a/src/zenml/zen_server/routers/webhook_integration_endpoints.py b/src/zenml/zen_server/routers/webhook_integration_endpoints.py index 02629b71dea..1806703f145 100644 --- a/src/zenml/zen_server/routers/webhook_integration_endpoints.py +++ b/src/zenml/zen_server/routers/webhook_integration_endpoints.py @@ -24,7 +24,6 @@ status, ) from fastapi.responses import Response -from pydantic import SecretStr from starlette.concurrency import run_in_threadpool from starlette.datastructures import Headers @@ -45,10 +44,6 @@ WebhookIntegrationSecretResponse, WebhookIntegrationUpdate, ) -from zenml.utils.secret_utils import ( - is_secret_reference, - parse_secret_reference, -) from zenml.webhooks import ( WebhookAuthenticationError, WebhookPayloadError, @@ -85,24 +80,6 @@ ) -def _verify_webhook_secret_reference_access( - secret: SecretStr | None, -) -> None: - """Verify access to a referenced ZenML secret value. - - Args: - secret: A direct signing secret or ZenML secret reference. - """ - if secret is None or not is_secret_reference(secret): - return - - reference = parse_secret_reference(secret) - referenced_secret = zen_store().get_secret_by_name_or_id(reference.name) - verify_permission_for_model( - model=referenced_secret, action=Action.READ_SECRET_VALUE - ) - - @management_router.post("") @async_fastapi_endpoint_wrapper def create_webhook( @@ -118,7 +95,6 @@ def create_webhook( The created integration and any generated signing secret. """ verify_permission_for_model(model=integration, action=Action.CREATE) - _verify_webhook_secret_reference_access(integration.secret) result = zen_store().create_webhook_integration(integration) return dehydrate_response_model(result) @@ -192,7 +168,6 @@ def update_webhook( webhook_id, hydrate=False ) verify_permission_for_model(model=integration, action=Action.UPDATE) - _verify_webhook_secret_reference_access(update.secret) updated_integration = zen_store().update_webhook_integration( integration_id=integration.id, update=update, @@ -293,20 +268,22 @@ def _receive_webhook_event( request fails authentication or payload validation. """ try: - integration = zen_store().get_webhook_integration(integration_id) + stored_type, active, secret_id = zen_store().get_webhook_intake_config( + integration_id + ) except KeyError as error: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) from error - if integration.webhook_type != webhook_type: + if stored_type != webhook_type: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) - secret = zen_store().get_webhook_integration_secret(integration_id) + secret = zen_store().get_webhook_secret(secret_id) adapter = get_webhook_adapter(webhook_type) try: adapter.authenticate(body=body, headers=headers, secret=secret) except WebhookAuthenticationError as error: - if integration.active: + if active: zen_store().record_webhook_event( integration_id, WebhookEventStatsUpdate( @@ -318,7 +295,7 @@ def _receive_webhook_event( detail="Invalid webhook authentication.", ) from error - if not integration.active: + if not active: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Webhook integration is inactive.", diff --git a/src/zenml/zen_stores/migrations/versions/7c0d9e4a1b2f_add_webhook_integrations.py b/src/zenml/zen_stores/migrations/versions/7c0d9e4a1b2f_add_webhook_integrations.py index 8f204ae6338..97be2bea021 100644 --- a/src/zenml/zen_stores/migrations/versions/7c0d9e4a1b2f_add_webhook_integrations.py +++ b/src/zenml/zen_stores/migrations/versions/7c0d9e4a1b2f_add_webhook_integrations.py @@ -44,11 +44,6 @@ def upgrade() -> None: "webhook_type", sqlmodel.sql.sqltypes.AutoString(), nullable=False ), sa.Column("active", sa.Boolean(), nullable=False), - sa.Column( - "stats", - sa.TEXT(), - nullable=False, - ), sa.ForeignKeyConstraint( ["project_id"], ["project.id"], @@ -80,10 +75,46 @@ def upgrade() -> None: ["webhook_type"], unique=False, ) + op.create_table( + "webhook_integration_stats", + sa.Column("webhook_id", sa.Uuid(), nullable=False), + sa.Column( + "received_count", sa.Integer(), nullable=False, server_default="0" + ), + sa.Column( + "accepted_count", sa.Integer(), nullable=False, server_default="0" + ), + sa.Column( + "auth_failed_count", + sa.Integer(), + nullable=False, + server_default="0", + ), + sa.Column( + "invalid_payload_count", + sa.Integer(), + nullable=False, + server_default="0", + ), + sa.Column("last_received_at", sa.DateTime(), nullable=True), + sa.Column("last_accepted_at", sa.DateTime(), nullable=True), + sa.Column("last_error_at", sa.DateTime(), nullable=True), + sa.Column("last_error_summary", sa.TEXT(), nullable=True), + sa.ForeignKeyConstraint( + ["webhook_id"], + ["webhook_integration.id"], + name=( + "fk_webhook_integration_stats_webhook_id_webhook_integration" + ), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("webhook_id"), + ) def downgrade() -> None: """Drop the webhook integration table.""" + op.drop_table("webhook_integration_stats") op.drop_index( "ix_webhook_integration_webhook_type", table_name="webhook_integration", diff --git a/src/zenml/zen_stores/schemas/__init__.py b/src/zenml/zen_stores/schemas/__init__.py index f506995aa91..3055cc345cc 100644 --- a/src/zenml/zen_stores/schemas/__init__.py +++ b/src/zenml/zen_stores/schemas/__init__.py @@ -112,6 +112,7 @@ from zenml.zen_stores.schemas.user_schemas import UserSchema from zenml.zen_stores.schemas.webhook_integration_schemas import ( WebhookIntegrationSchema, + WebhookIntegrationStatsSchema, ) __all__ = [ @@ -176,4 +177,5 @@ "TriggerSnapshotSchema", "TriggerExecutionSchema", "WebhookIntegrationSchema", + "WebhookIntegrationStatsSchema", ] diff --git a/src/zenml/zen_stores/schemas/webhook_integration_schemas.py b/src/zenml/zen_stores/schemas/webhook_integration_schemas.py index bf251f7b67e..22824c995d4 100644 --- a/src/zenml/zen_stores/schemas/webhook_integration_schemas.py +++ b/src/zenml/zen_stores/schemas/webhook_integration_schemas.py @@ -11,13 +11,16 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express # or implied. See the License for the specific language governing # permissions and limitations under the License. -"""SQL schema for webhook integrations.""" +"""SQL schemas for webhook integrations.""" -from typing import Any +from datetime import datetime +from typing import Any, Sequence from uuid import UUID from sqlalchemy import TEXT, Column, UniqueConstraint -from sqlmodel import Field, Relationship +from sqlalchemy.orm import joinedload, selectinload +from sqlalchemy.sql.base import ExecutableOption +from sqlmodel import Field, Relationship, SQLModel from zenml.constants import API, VERSION_1, WEBHOOKS from zenml.models import ( @@ -38,6 +41,7 @@ ) from zenml.zen_stores.schemas.secret_schemas import SecretSchema from zenml.zen_stores.schemas.user_schemas import UserSchema +from zenml.zen_stores.schemas.utils import jl_arg class WebhookIntegrationSchema(NamedSchema, table=True): @@ -86,27 +90,38 @@ class WebhookIntegrationSchema(NamedSchema, table=True): ) webhook_type: str active: bool = Field(default=True) - stats: str = Field( - default=WebhookIntegrationStats().model_dump_json(), - sa_column=Column(TEXT, nullable=False), + stats: "WebhookIntegrationStatsSchema" = Relationship( + back_populates="webhook", + sa_relationship_kwargs={ + "cascade": "all, delete-orphan", + "single_parent": True, + "uselist": False, + }, ) - @property - def parsed_stats(self) -> WebhookIntegrationStats: - """Parse persisted intake statistics. - - Returns: - The typed intake statistics. - """ - return WebhookIntegrationStats.model_validate_json(self.stats or "{}") - - def set_stats(self, stats: WebhookIntegrationStats) -> None: - """Persist typed intake statistics. + @classmethod + def get_query_options( + cls, + include_metadata: bool = False, + include_resources: bool = False, + **kwargs: Any, + ) -> Sequence[ExecutableOption]: + """Get query options for webhook integrations. Args: - stats: The typed intake statistics to persist. + include_metadata: Whether statistics will be included. + include_resources: Whether related resources will be included. + **kwargs: Additional query option arguments. + + Returns: + Query options for the requested response shape. """ - self.stats = stats.model_dump_json() + options: list[ExecutableOption] = [] + if include_metadata: + options.append(selectinload(jl_arg(cls.stats))) + if include_resources: + options.append(joinedload(jl_arg(cls.user))) + return options @classmethod def from_request( @@ -149,7 +164,7 @@ def to_model( metadata = None if include_metadata: metadata = WebhookIntegrationResponseMetadata( - stats=self.parsed_stats + stats=self.stats.to_model() ) resources = None @@ -194,3 +209,41 @@ def update( self.active = update.active self.updated = utc_now() return self + + +class WebhookIntegrationStatsSchema(SQLModel, table=True): + """SQL schema for webhook intake statistics.""" + + __tablename__ = "webhook_integration_stats" + + webhook_id: UUID = build_foreign_key_field( + source=__tablename__, + target=WebhookIntegrationSchema.__tablename__, + source_column="webhook_id", + target_column="id", + ondelete="CASCADE", + nullable=False, + primary_key=True, + ) + received_count: int = 0 + accepted_count: int = 0 + auth_failed_count: int = 0 + invalid_payload_count: int = 0 + last_received_at: datetime | None = None + last_accepted_at: datetime | None = None + last_error_at: datetime | None = None + last_error_summary: str | None = Field( + default=None, sa_column=Column(TEXT, nullable=True) + ) + + webhook: WebhookIntegrationSchema = Relationship(back_populates="stats") + + def to_model(self) -> WebhookIntegrationStats: + """Convert persisted statistics to their domain model. + + Returns: + The webhook intake statistics. + """ + return WebhookIntegrationStats.model_validate( + self, from_attributes=True + ) diff --git a/src/zenml/zen_stores/sql_zen_store.py b/src/zenml/zen_stores/sql_zen_store.py index 0e778706456..5d7e923a3bc 100644 --- a/src/zenml/zen_stores/sql_zen_store.py +++ b/src/zenml/zen_stores/sql_zen_store.py @@ -181,6 +181,7 @@ StoreType, TaggableResourceTypes, VisualizationResourceTypes, + WebhookType, ) from zenml.exceptions import ( AuthorizationException, @@ -394,11 +395,7 @@ from zenml.utils.networking_utils import ( replace_localhost_with_internal_hostname, ) -from zenml.utils.secret_utils import ( - PlainSerializedSecretStr, - is_secret_reference, - parse_secret_reference, -) +from zenml.utils.secret_utils import PlainSerializedSecretStr from zenml.utils.string_utils import ( format_name_template, random_str, @@ -471,6 +468,7 @@ TriggerSnapshotSchema, UserSchema, WebhookIntegrationSchema, + WebhookIntegrationStatsSchema, ) from zenml.zen_stores.schemas.artifact_visualization_schemas import ( ArtifactVisualizationSchema, @@ -513,9 +511,6 @@ logger = get_logger(__name__) _WEBHOOK_SECRET_VALUE_KEY = "secret" -_WEBHOOK_SECRET_REFERENCE_KEY = "secret_reference" -_WEBHOOK_SECRET_REFERENCE_ID_KEY = "secret_reference_id" -_WEBHOOK_SECRET_REFERENCE_VALUE_KEY = "secret_reference_key" ZENML_SQLITE_DB_FILENAME = "zenml.db" @@ -8144,58 +8139,17 @@ def _create_webhook_integration_secret( Returns: The internal secret ID. """ - values = self._get_webhook_integration_secret_storage_values( - secret=secret, session=session - ) return self._create_secret_schema( SecretRequest( user=self._get_active_user(session=session).id, name=f"webhook-{uuid.uuid4().hex}", private=False, - values=values, + values={_WEBHOOK_SECRET_VALUE_KEY: secret}, ), session=session, internal=True, ).id - def _get_webhook_integration_secret_storage_values( - self, secret: str, session: Session - ) -> Dict[str, Optional[str]]: - """Build internal storage values for a webhook signing credential. - - Args: - secret: A direct signing secret or ZenML secret reference. - session: The active database session. - - Returns: - Values to persist in the internal webhook secret. - - Raises: - KeyError: If the referenced secret or key does not exist. - ValueError: If the referenced value is empty. - """ - if not is_secret_reference(secret): - return {_WEBHOOK_SECRET_VALUE_KEY: secret} - - reference = parse_secret_reference(secret) - referenced_secret = self._get_visible_secret_schema( - reference.name, session=session - ) - referenced_values = self._get_secret_values(referenced_secret.id) - if reference.key not in referenced_values: - raise KeyError( - f"Secret {reference.name} does not contain key " - f"{reference.key}." - ) - if not referenced_values[reference.key].strip(): - raise ValueError("Webhook signing secret must not be empty.") - - return { - _WEBHOOK_SECRET_REFERENCE_KEY: secret, - _WEBHOOK_SECRET_REFERENCE_ID_KEY: str(referenced_secret.id), - _WEBHOOK_SECRET_REFERENCE_VALUE_KEY: reference.key, - } - def create_webhook_integration( self, integration: WebhookIntegrationRequest ) -> WebhookIntegrationCreateResponse: @@ -8229,7 +8183,11 @@ def create_webhook_integration( schema = WebhookIntegrationSchema.from_request( request=integration, secret_id=secret_id ) + stats_schema = WebhookIntegrationStatsSchema( + webhook_id=schema.id + ) session.add(schema) + session.add(stats_schema) session.commit() except Exception: session.rollback() @@ -8261,10 +8219,14 @@ def get_webhook_integration( The webhook integration. """ with Session(self.engine) as session: + query_options = WebhookIntegrationSchema.get_query_options( + include_metadata=hydrate, include_resources=True + ) schema = self._get_schema_by_id( resource_id=integration_id, schema_class=WebhookIntegrationSchema, session=session, + query_options=query_options, ) return schema.to_model( include_metadata=hydrate, include_resources=True @@ -8294,6 +8256,7 @@ def list_webhook_integrations( table=WebhookIntegrationSchema, filter_model=filter_model, hydrate=hydrate, + apply_query_options_from_schema=True, ) def update_webhook_integration( @@ -8320,12 +8283,13 @@ def update_webhook_integration( resource=update, schema=schema, session=session ) if update.secret is not None: - values = self._get_webhook_integration_secret_storage_values( - secret=update.secret.get_secret_value(), session=session - ) self._update_secret_values( secret_id=schema.secret_id, - values=values, + values={ + _WEBHOOK_SECRET_VALUE_KEY: ( + update.secret.get_secret_value() + ) + }, overwrite=True, ) schema.update(update) @@ -8366,9 +8330,6 @@ def rotate_webhook_integration_secret( Returns: The newly active signing secret. - Raises: - IllegalOperationError: If the integration uses a secret reference - or the replacement secret is a secret reference. """ with Session(self.engine) as session: schema = self._get_schema_by_id( @@ -8376,29 +8337,6 @@ def rotate_webhook_integration_secret( schema_class=WebhookIntegrationSchema, session=session, ) - current_values = self._get_secret_values(schema.secret_id) - if _WEBHOOK_SECRET_REFERENCE_KEY in current_values: - reference = current_values[_WEBHOOK_SECRET_REFERENCE_KEY] - parsed_reference = parse_secret_reference(reference) - raise IllegalOperationError( - "Cannot rotate this webhook integration signing secret " - f"because it uses the secret reference `{reference}`. " - "Update the referenced value instead with " - f"`zenml secret update {parsed_reference.name} " - f"--{parsed_reference.key}=`, or update the " - "webhook integration to use a managed secret." - ) - - if request.secret is not None and is_secret_reference( - request.secret - ): - raise IllegalOperationError( - "Secret references cannot be configured through secret " - "rotation. Use `zenml webhook update " - f"{schema.name} --secret='" - f"{request.secret.get_secret_value()}'` instead." - ) - secret = ( request.secret.get_secret_value() if request.secret is not None @@ -8416,47 +8354,43 @@ def rotate_webhook_integration_secret( secret=PlainSerializedSecretStr(secret) ) - def get_webhook_integration_secret(self, integration_id: UUID) -> str: - """Get the signing secret used to validate webhook requests. + def get_webhook_intake_config( + self, integration_id: UUID + ) -> Tuple[WebhookType, bool, UUID]: + """Get the minimal configuration required for webhook intake. Args: integration_id: The webhook integration ID. Returns: - The signing secret. + The webhook type, active state, and internal secret ID. Raises: - RuntimeError: If the referenced secret value cannot be resolved or - is empty. + KeyError: If the webhook integration does not exist. """ with Session(self.engine) as session: - schema = self._get_schema_by_id( - resource_id=integration_id, - schema_class=WebhookIntegrationSchema, - session=session, - ) - values = self._get_secret_values(schema.secret_id) + config = session.exec( + select( + WebhookIntegrationSchema.webhook_type, + WebhookIntegrationSchema.active, + WebhookIntegrationSchema.secret_id, + ).where(col(WebhookIntegrationSchema.id) == integration_id) + ).first() + if config is None: + raise KeyError(f"Webhook integration {integration_id} not found.") + webhook_type, active, secret_id = config + return WebhookType(webhook_type), active, secret_id - if _WEBHOOK_SECRET_VALUE_KEY in values: - return values[_WEBHOOK_SECRET_VALUE_KEY] + def get_webhook_secret(self, secret_id: UUID) -> str: + """Get a webhook signing secret from the configured secrets store. - try: - reference_id = UUID(values[_WEBHOOK_SECRET_REFERENCE_ID_KEY]) - reference_key = values[_WEBHOOK_SECRET_REFERENCE_VALUE_KEY] - referenced_values = self._get_secret_values(reference_id) - secret = referenced_values[reference_key] - except (KeyError, ValueError) as error: - raise RuntimeError( - "The webhook integration signing secret reference can no " - "longer be resolved." - ) from error + Args: + secret_id: The internal webhook secret ID. - if not secret.strip(): - raise RuntimeError( - "The webhook integration signing secret reference resolves " - "to an empty value." - ) - return secret + Returns: + The signing secret. + """ + return self._get_secret_values(secret_id)[_WEBHOOK_SECRET_VALUE_KEY] def record_webhook_event( self, integration_id: UUID, update: WebhookEventStatsUpdate @@ -8471,35 +8405,42 @@ def record_webhook_event( KeyError: If the webhook integration no longer exists. """ now = utc_now() + values: Dict[str, Any] = { + "received_count": ( + WebhookIntegrationStatsSchema.received_count + 1 + ), + "last_received_at": now, + } + if update.accepted: + values["accepted_count"] = ( + WebhookIntegrationStatsSchema.accepted_count + 1 + ) + values["last_accepted_at"] = now + elif update.auth_failed: + values["auth_failed_count"] = ( + WebhookIntegrationStatsSchema.auth_failed_count + 1 + ) + elif update.invalid_payload: + values["invalid_payload_count"] = ( + WebhookIntegrationStatsSchema.invalid_payload_count + 1 + ) + if update.error_summary is not None: + values["last_error_at"] = now + values["last_error_summary"] = update.error_summary + with Session(self.engine) as session: - schema = session.exec( - select(WebhookIntegrationSchema) - .where(col(WebhookIntegrationSchema.id) == integration_id) - .with_for_update() - ).first() - if schema is None: + result = session.exec( + sqlalchemy.update(WebhookIntegrationStatsSchema) + .where( + col(WebhookIntegrationStatsSchema.webhook_id) + == integration_id + ) + .values(**values) + ) + if result.rowcount == 0: raise KeyError( f"Webhook integration {integration_id} not found." ) - - stats = schema.parsed_stats - stats.received_count += 1 - stats.last_received_at = now - - if update.accepted: - stats.accepted_count += 1 - stats.last_accepted_at = now - elif update.auth_failed: - stats.auth_failed_count += 1 - elif update.invalid_payload: - stats.invalid_payload_count += 1 - - if update.error_summary is not None: - stats.last_error_at = now - stats.last_error_summary = update.error_summary - - schema.set_stats(stats) - session.add(schema) session.commit() # -------------------- Triggers --------------------- diff --git a/tests/integration/functional/webhooks/test_cli.py b/tests/integration/functional/webhooks/test_cli.py index 7292289c543..e9ba34d3d7b 100644 --- a/tests/integration/functional/webhooks/test_cli.py +++ b/tests/integration/functional/webhooks/test_cli.py @@ -138,46 +138,3 @@ def test_webhook_cli_does_not_echo_user_supplied_secret( assert "secret" not in integration.model_dump() finally: _delete_if_exists(name) - - -def test_webhook_cli_rejects_rotation_for_secret_reference( - clean_client, -): - """The CLI guides referenced credentials to the secret update command.""" - runner = cli_runner() - integration_name = sample_name("webhook-cli-reference") - secret_name = sample_name("webhook-cli-secret") - clean_client.create_secret(secret_name, values={"key": "secret-value"}) - - try: - create_result = runner.invoke( - create_command, - [ - integration_name, - "--type", - WebhookType.CUSTOM.value, - "--secret", - "managed-secret", - ], - ) - assert create_result.exit_code == 0, create_result.output - - update_result = runner.invoke( - update_command, - [ - integration_name, - "--secret", - f"{{{{{secret_name}.key}}}}", - ], - ) - assert update_result.exit_code == 0, update_result.output - - rotate_result = runner.invoke( - rotate_secret_command, [integration_name] - ) - - assert rotate_result.exit_code != 0 - assert "zenml secret update" in rotate_result.output - finally: - _delete_if_exists(integration_name) - clean_client.delete_secret(secret_name) diff --git a/tests/integration/functional/webhooks/test_client.py b/tests/integration/functional/webhooks/test_client.py index 9246485902c..f0550f36582 100644 --- a/tests/integration/functional/webhooks/test_client.py +++ b/tests/integration/functional/webhooks/test_client.py @@ -16,7 +16,6 @@ from tests.integration.functional.utils import sample_name from zenml.client import Client from zenml.enums import WebhookType -from zenml.exceptions import IllegalOperationError from zenml.zen_stores.sql_zen_store import SqlZenStore @@ -137,41 +136,3 @@ def test_client_update_webhook_by_name_and_id( assert updated_by_id.active is True finally: clean_client.delete_webhook(integration_id) - - -def test_client_rejects_rotation_for_referenced_webhook_secret(clean_client): - """Referenced webhook secrets can be updated but not rotated.""" - integration_name = sample_name("webhook-client-reference") - secret_name = sample_name("webhook-signing-secret") - clean_client.create_secret(secret_name, values={"key": "initial-secret"}) - result = clean_client.create_webhook( - name=integration_name, - webhook_type=WebhookType.CUSTOM, - secret="managed-secret", - ) - - try: - clean_client.update_webhook( - integration_name, - secret=f"{{{{{secret_name}.key}}}}", - ) - - with pytest.raises(IllegalOperationError, match="zenml secret update"): - clean_client.rotate_webhook_secret(integration_name) - - clean_client.update_webhook( - integration_name, - secret="managed-secret-again", - ) - with pytest.raises(IllegalOperationError, match="webhook update"): - clean_client.rotate_webhook_secret( - integration_name, - secret=f"{{{{{secret_name}.key}}}}", - ) - - rotated = clean_client.rotate_webhook_secret(integration_name) - - assert rotated.secret.get_secret_value() - finally: - clean_client.delete_webhook(result.webhook.id) - clean_client.delete_secret(secret_name) diff --git a/tests/integration/functional/webhooks/test_events_endpoint.py b/tests/integration/functional/webhooks/test_events_endpoint.py index fefd8efb042..8aeb56c644f 100644 --- a/tests/integration/functional/webhooks/test_events_endpoint.py +++ b/tests/integration/functional/webhooks/test_events_endpoint.py @@ -109,52 +109,6 @@ def test_webhook_intake_accepts_valid_custom_delivery( assert updated.stats.last_accepted_at is not None -def test_webhook_intake_resolves_updated_secret_reference( - clean_project, -): - """Webhook intake resolves the current referenced secret value.""" - store = _require_rest_store(clean_project) - secret_name = sample_name("webhook-intake-reference") - clean_project.create_secret(secret_name, values={"key": "initial-secret"}) - result = clean_project.create_webhook( - name=sample_name("webhook-intake-reference-integration"), - webhook_type=WebhookType.CUSTOM, - secret=f"{{{{{secret_name}.key}}}}", - ) - integration = result.webhook - body = b'{"pipeline":"training"}' - - try: - response = _post_webhook( - store=store, - endpoint_path=integration.endpoint_path, - body=body, - headers={ - "X-ZenML-Event": "pipeline.ready", - "X-ZenML-Signature-256": _signature("initial-secret", body), - }, - ) - assert response.status_code == 202 - - clean_project.update_secret( - secret_name, add_or_update_values={"key": "updated-secret"} - ) - response = _post_webhook( - store=store, - endpoint_path=integration.endpoint_path, - body=body, - headers={ - "X-ZenML-Event": "pipeline.ready", - "X-ZenML-Signature-256": _signature("updated-secret", body), - }, - ) - - assert response.status_code == 202 - finally: - clean_project.delete_webhook(integration.id) - clean_project.delete_secret(secret_name) - - @pytest.mark.parametrize( ( "scenario", diff --git a/tests/integration/functional/webhooks/test_zen_store.py b/tests/integration/functional/webhooks/test_zen_store.py index 841321ece8c..99ccd2dba43 100644 --- a/tests/integration/functional/webhooks/test_zen_store.py +++ b/tests/integration/functional/webhooks/test_zen_store.py @@ -12,6 +12,7 @@ # or implied. See the License for the specific language governing # permissions and limitations under the License. import pytest +from sqlalchemy import event from sqlalchemy.exc import IntegrityError from sqlmodel import Session, select @@ -19,6 +20,7 @@ from zenml.enums import WebhookType from zenml.exceptions import IllegalOperationError from zenml.models import ( + WebhookEventStatsUpdate, WebhookIntegrationFilter, WebhookIntegrationRequest, WebhookIntegrationRotateSecretRequest, @@ -80,10 +82,18 @@ def test_zen_store_webhook_integration_lifecycle(clean_client): assert integration.get_resources().user is not None assert integration.get_resources().user.id == clean_client.active_user.id + store.record_webhook_event( + integration.id, WebhookEventStatsUpdate(accepted=True) + ) + integration = store.get_webhook_integration(integration.id) + + assert integration.stats.received_count == 1 + assert integration.stats.accepted_count == 1 + by_id = store.get_webhook_integration(integration.id) assert by_id.id == integration.id - assert by_id.stats.received_count == 0 + assert by_id.stats.received_count == 1 assert by_id.get_resources().user is not None assert by_id.get_resources().user.id == clean_client.active_user.id @@ -97,7 +107,10 @@ def test_zen_store_webhook_integration_lifecycle(clean_client): ) assert integration.id in {item.id for item in filtered.items} - assert all(item.stats.received_count == 0 for item in filtered.items) + filtered_integration = next( + item for item in filtered.items if item.id == integration.id + ) + assert filtered_integration.stats.received_count == 1 updated_name = sample_name("webhook-store-updated") updated = store.update_webhook_integration( @@ -132,6 +145,51 @@ def test_zen_store_webhook_integration_lifecycle(clean_client): store.get_webhook_integration(integration.id) +def test_sql_store_webhook_intake_uses_one_config_read_and_stats_update( + clean_client, +) -> None: + """Webhook intake uses one SQL statement per database operation.""" + store = clean_client.zen_store + if not isinstance(store, SqlZenStore): + pytest.skip("Local SQL store behavior is required for this test.") + + result = store.create_webhook_integration( + WebhookIntegrationRequest( + project=clean_client.active_project.id, + name=sample_name("webhook-intake-query-count"), + webhook_type=WebhookType.CUSTOM, + ) + ) + statements = [] + + def capture_statement( + connection, cursor, statement, parameters, context, executemany + ): + statements.append(statement) + + event.listen(store.engine, "before_cursor_execute", capture_statement) + try: + webhook_type, active, _ = store.get_webhook_intake_config( + result.webhook.id + ) + + assert webhook_type == WebhookType.CUSTOM + assert active is True + assert len(statements) == 1 + assert statements[0].lstrip().upper().startswith("SELECT") + + statements.clear() + store.record_webhook_event( + result.webhook.id, WebhookEventStatsUpdate(accepted=True) + ) + + assert len(statements) == 1 + assert statements[0].lstrip().upper().startswith("UPDATE") + finally: + event.remove(store.engine, "before_cursor_execute", capture_statement) + store.delete_webhook_integration(result.webhook.id) + + def test_sql_store_protects_webhook_owned_secret(clean_client) -> None: """Webhook-owned secrets require explicit webhook deletion.""" store = clean_client.zen_store @@ -169,9 +227,7 @@ def test_sql_store_protects_webhook_owned_secret(clean_client) -> None: secret_id=secret_id, session=session ) - assert store.get_webhook_integration_secret(webhook_id) == ( - "owned-secret" - ) + assert store.get_webhook_secret(secret_id) == "owned-secret" assert store.get_webhook_integration(webhook_id).id == webhook_id store.delete_webhook_integration(webhook_id) @@ -184,39 +240,3 @@ def test_sql_store_protects_webhook_owned_secret(clean_client) -> None: store.delete_webhook_integration(webhook_id) except KeyError: pass - - -def test_sql_store_resolves_webhook_secret_references_lazily(clean_client): - """Webhook secret references resolve their current value at intake time.""" - store = clean_client.zen_store - if not isinstance(store, SqlZenStore): - pytest.skip("Webhook secret resolution is server-local behavior.") - - secret_name = sample_name("webhook-reference") - clean_client.create_secret(secret_name, values={"key": "initial-secret"}) - result = store.create_webhook_integration( - WebhookIntegrationRequest( - project=clean_client.active_project.id, - name=sample_name("webhook-store-reference"), - webhook_type=WebhookType.CUSTOM, - secret=f"{{{{{secret_name}.key}}}}", - ) - ) - - try: - assert ( - store.get_webhook_integration_secret(result.webhook.id) - == "initial-secret" - ) - - clean_client.update_secret( - secret_name, add_or_update_values={"key": "rotated-secret"} - ) - - assert ( - store.get_webhook_integration_secret(result.webhook.id) - == "rotated-secret" - ) - finally: - store.delete_webhook_integration(result.webhook.id) - clean_client.delete_secret(secret_name) diff --git a/tests/unit/zen_server/endpoints/test_webhook_integration_endpoints.py b/tests/unit/zen_server/endpoints/test_webhook_integration_endpoints.py index a5610e3cac4..fdf8d1ed8f1 100644 --- a/tests/unit/zen_server/endpoints/test_webhook_integration_endpoints.py +++ b/tests/unit/zen_server/endpoints/test_webhook_integration_endpoints.py @@ -16,11 +16,9 @@ import pytest from fastapi import HTTPException, status -from pydantic import SecretStr from zenml.constants import API, VERSION_1, WEBHOOKS from zenml.enums import WebhookType -from zenml.models import WebhookIntegrationUpdate from zenml.webhooks import WebhookAuthenticationError, WebhookPayloadError from zenml.zen_server.routers import webhook_integration_endpoints as endpoints @@ -38,13 +36,19 @@ def __init__(self, integration: SimpleNamespace | None) -> None: self.integration = integration self.secret_requests = 0 self.records = [] + self.secret_id = uuid4() - def get_webhook_integration(self, integration_id): + def get_webhook_intake_config(self, integration_id): if self.integration is None: raise KeyError(integration_id) - return self.integration + return ( + self.integration.webhook_type, + self.integration.active, + self.secret_id, + ) - def get_webhook_integration_secret(self, integration_id): + def get_webhook_secret(self, secret_id): + assert secret_id == self.secret_id self.secret_requests += 1 return "webhook-secret" @@ -88,84 +92,6 @@ def _receive(integration_id): ) -def test_verify_webhook_secret_reference_access_checks_value_permission( - monkeypatch, -) -> None: - """Referenced credentials require permission to read the secret value.""" - referenced_secret = SimpleNamespace(name="webhook-secret") - store = SimpleNamespace( - get_secret_by_name_or_id=lambda name: referenced_secret - ) - verified = [] - monkeypatch.setattr(endpoints, "zen_store", lambda: store) - monkeypatch.setattr( - endpoints, - "verify_permission_for_model", - lambda **kwargs: verified.append(kwargs), - ) - - endpoints._verify_webhook_secret_reference_access( - SecretStr("{{webhook-secret.key}}") - ) - - assert verified == [ - { - "model": referenced_secret, - "action": endpoints.Action.READ_SECRET_VALUE, - } - ] - - -def test_update_checks_integration_permission_before_secret_access( - monkeypatch, -) -> None: - """Integration update access is checked before referenced secret access.""" - integration_id = uuid4() - integration = SimpleNamespace(id=integration_id) - updated_integration = SimpleNamespace(id=integration_id) - calls = [] - - class _UpdateStore: - def get_webhook_integration(self, integration_id, hydrate): - calls.append("get_integration") - return integration - - def update_webhook_integration(self, integration_id, update): - calls.append("update_integration") - return updated_integration - - monkeypatch.setattr(endpoints, "zen_store", _UpdateStore) - monkeypatch.setattr( - endpoints, - "verify_permission_for_model", - lambda **kwargs: calls.append(f"verify_{kwargs['action'].value}"), - ) - monkeypatch.setattr( - endpoints, - "_verify_webhook_secret_reference_access", - lambda secret: calls.append("verify_secret_access"), - ) - monkeypatch.setattr( - endpoints, - "dehydrate_response_model", - lambda model: model, - ) - - result = endpoints.update_webhook.__wrapped__( - webhook_id=integration_id, - update=WebhookIntegrationUpdate(secret="{{webhook.key}}"), - _=SimpleNamespace(), - ) - - assert result is updated_integration - assert calls == [ - "get_integration", - "verify_update", - "verify_secret_access", - "update_integration", - ] - - @pytest.mark.parametrize( ( "stored_type", diff --git a/tests/unit/zen_stores/schemas/test_webhook_integration_schemas.py b/tests/unit/zen_stores/schemas/test_webhook_integration_schemas.py index f07412ed0d4..65c28646984 100644 --- a/tests/unit/zen_stores/schemas/test_webhook_integration_schemas.py +++ b/tests/unit/zen_stores/schemas/test_webhook_integration_schemas.py @@ -30,11 +30,12 @@ ) from zenml.zen_stores.schemas.webhook_integration_schemas import ( WebhookIntegrationSchema, + WebhookIntegrationStatsSchema, ) def _webhook_integration_schema() -> WebhookIntegrationSchema: - return WebhookIntegrationSchema( + schema = WebhookIntegrationSchema( id=uuid4(), name="github-intake", project_id=uuid4(), @@ -42,7 +43,10 @@ def _webhook_integration_schema() -> WebhookIntegrationSchema: secret_id=uuid4(), webhook_type=WebhookType.GITHUB.value, active=True, - stats=WebhookIntegrationStats( + ) + schema.stats = WebhookIntegrationStatsSchema( + webhook_id=schema.id, + **WebhookIntegrationStats( received_count=3, accepted_count=1, auth_failed_count=1, @@ -51,8 +55,9 @@ def _webhook_integration_schema() -> WebhookIntegrationSchema: last_accepted_at=datetime(2026, 7, 9, 8, 1, 0), last_error_at=datetime(2026, 7, 9, 8, 2, 0), last_error_summary="Invalid webhook signature.", - ).model_dump_json(), + ).model_dump(), ) + return schema @pytest.mark.parametrize( @@ -111,12 +116,12 @@ def test_webhook_integration_requests_allow_missing_secret() -> None: assert secret_request.secret is None -def test_webhook_integration_update_accepts_secret_reference() -> None: - """Webhook integration updates accept a secret reference.""" - update = WebhookIntegrationUpdate(secret="{{webhook.secret}}") +def test_webhook_integration_update_accepts_direct_secret() -> None: + """Webhook integration updates accept a direct signing secret.""" + update = WebhookIntegrationUpdate(secret="webhook-secret") assert update.secret is not None - assert update.secret.get_secret_value() == "{{webhook.secret}}" + assert update.secret.get_secret_value() == "webhook-secret" def test_webhook_integration_schema_to_model_includes_body_and_metadata() -> ( @@ -145,10 +150,12 @@ def test_webhook_integration_schema_to_model_includes_body_and_metadata() -> ( assert response.stats.last_error_summary == "Invalid webhook signature." -def test_webhook_integration_schema_to_model_defaults_missing_stats() -> None: - """Webhook integration schemas default missing stats fields.""" +def test_webhook_integration_stats_schema_defaults_missing_stats() -> None: + """Webhook integration stats schemas default missing fields.""" schema = _webhook_integration_schema() - schema.stats = '{"received_count": 3, "future_count": 7}' + schema.stats = WebhookIntegrationStatsSchema( + webhook_id=schema.id, received_count=3 + ) response = schema.to_model(include_metadata=True) @@ -159,17 +166,6 @@ def test_webhook_integration_schema_to_model_defaults_missing_stats() -> None: assert response.stats.last_received_at is None -def test_webhook_integration_schema_can_update_serialized_stats() -> None: - """Webhook integration schemas serialize typed stats.""" - schema = _webhook_integration_schema() - stats = WebhookIntegrationStats(received_count=5) - - schema.set_stats(stats) - - assert schema.parsed_stats.received_count == 5 - assert schema.parsed_stats.accepted_count == 0 - - def test_webhook_integration_schema_to_model_can_include_empty_resources() -> ( None ): From 06079a609f79bba29377681ad333840bd7c917ab Mon Sep 17 00:00:00 2001 From: Iason Andriopoulos Date: Thu, 16 Jul 2026 13:04:24 +0300 Subject: [PATCH 17/18] Separate secret & data updates --- src/zenml/cli/webhook_integration.py | 9 ------ src/zenml/client.py | 6 +--- .../models/v2/core/webhook_integration.py | 4 --- src/zenml/zen_stores/sql_zen_store.py | 11 ------- .../functional/webhooks/test_zen_store.py | 29 ++++++++++++++++++- .../test_webhook_integration_schemas.py | 12 +++----- 6 files changed, 33 insertions(+), 38 deletions(-) diff --git a/src/zenml/cli/webhook_integration.py b/src/zenml/cli/webhook_integration.py index 5c2fee441ab..5c54f11b229 100644 --- a/src/zenml/cli/webhook_integration.py +++ b/src/zenml/cli/webhook_integration.py @@ -123,17 +123,10 @@ def list_webhooks( @click.option( "--active/--inactive", "active", default=None, help="Set active state." ) -@click.option( - "--secret", - type=str, - default=None, - help="Set a replacement signing secret.", -) def update_webhook( name_or_id: str, new_name: str | None, active: bool | None, - secret: str | None, ) -> None: """Update a webhook. @@ -141,13 +134,11 @@ def update_webhook( name_or_id: The webhook name or ID. new_name: The new webhook name. active: The new active state. - secret: A replacement signing secret. """ integration = Client().update_webhook( name_id_or_prefix=name_or_id, name=new_name, active=active, - secret=secret, ) cli_utils.print_pydantic_model( title=f"Webhook '{integration.name}'", diff --git a/src/zenml/client.py b/src/zenml/client.py index c3c8b2917be..56ebfa42e09 100644 --- a/src/zenml/client.py +++ b/src/zenml/client.py @@ -6960,7 +6960,6 @@ def update_webhook( name_id_or_prefix: str | UUID, name: str | None = None, active: bool | None = None, - secret: str | None = None, project: str | UUID | None = None, ) -> WebhookIntegrationResponse: """Update a webhook. @@ -6969,7 +6968,6 @@ def update_webhook( name_id_or_prefix: The webhook name, ID, or ID prefix. name: The new webhook name. active: The new active state. - secret: A new signing secret. project: The project name or ID. Returns: @@ -6989,9 +6987,7 @@ def update_webhook( ).id return self.zen_store.update_webhook_integration( integration_id=integration_id, - update=WebhookIntegrationUpdate( - name=name, active=active, secret=secret - ), + update=WebhookIntegrationUpdate(name=name, active=active), ) @_fail_for_sql_zen_store diff --git a/src/zenml/models/v2/core/webhook_integration.py b/src/zenml/models/v2/core/webhook_integration.py index 90e9698a8f7..182e5648465 100644 --- a/src/zenml/models/v2/core/webhook_integration.py +++ b/src/zenml/models/v2/core/webhook_integration.py @@ -54,10 +54,6 @@ class WebhookIntegrationUpdate(BaseUpdate): name: str | None = Field(default=None, max_length=STR_FIELD_MAX_LENGTH) active: bool | None = None - secret: NonEmptyPlainSerializedSecretStr | None = Field( - default=None, - description="A replacement signing secret.", - ) class WebhookIntegrationResponseBody(ProjectScopedResponseBody): diff --git a/src/zenml/zen_stores/sql_zen_store.py b/src/zenml/zen_stores/sql_zen_store.py index 5d7e923a3bc..3d7cd573f05 100644 --- a/src/zenml/zen_stores/sql_zen_store.py +++ b/src/zenml/zen_stores/sql_zen_store.py @@ -8282,16 +8282,6 @@ def update_webhook_integration( self._verify_name_uniqueness( resource=update, schema=schema, session=session ) - if update.secret is not None: - self._update_secret_values( - secret_id=schema.secret_id, - values={ - _WEBHOOK_SECRET_VALUE_KEY: ( - update.secret.get_secret_value() - ) - }, - overwrite=True, - ) schema.update(update) session.add(schema) session.commit() @@ -8329,7 +8319,6 @@ def rotate_webhook_integration_secret( Returns: The newly active signing secret. - """ with Session(self.engine) as session: schema = self._get_schema_by_id( diff --git a/tests/integration/functional/webhooks/test_zen_store.py b/tests/integration/functional/webhooks/test_zen_store.py index 99ccd2dba43..351526fc23e 100644 --- a/tests/integration/functional/webhooks/test_zen_store.py +++ b/tests/integration/functional/webhooks/test_zen_store.py @@ -90,6 +90,13 @@ def test_zen_store_webhook_integration_lifecycle(clean_client): assert integration.stats.received_count == 1 assert integration.stats.accepted_count == 1 + with Session(store.engine) as session: + initial_secret_id = session.exec( + select(WebhookIntegrationSchema.secret_id).where( + WebhookIntegrationSchema.id == integration.id + ) + ).one() + by_id = store.get_webhook_integration(integration.id) assert by_id.id == integration.id @@ -115,7 +122,10 @@ def test_zen_store_webhook_integration_lifecycle(clean_client): updated_name = sample_name("webhook-store-updated") updated = store.update_webhook_integration( integration_id=integration.id, - update=WebhookIntegrationUpdate(name=updated_name, active=False), + update=WebhookIntegrationUpdate( + name=updated_name, + active=False, + ), ) assert updated.id == integration.id @@ -124,6 +134,14 @@ def test_zen_store_webhook_integration_lifecycle(clean_client): assert updated.get_resources().user is not None assert updated.get_resources().user.id == clean_client.active_user.id + with Session(store.engine) as session: + updated_secret_id = session.exec( + select(WebhookIntegrationSchema.secret_id).where( + WebhookIntegrationSchema.id == integration.id + ) + ).one() + assert updated_secret_id == initial_secret_id + inactive_integrations = store.list_webhook_integrations( WebhookIntegrationFilter(project=project_id, active=False) ) @@ -139,6 +157,15 @@ def test_zen_store_webhook_integration_lifecycle(clean_client): assert rotated.secret.get_secret_value() == "replacement-secret" + with Session(store.engine) as session: + rotated_secret_id = session.exec( + select(WebhookIntegrationSchema.secret_id).where( + WebhookIntegrationSchema.id == integration.id + ) + ).one() + assert rotated_secret_id == initial_secret_id + assert store.get_webhook_secret(rotated_secret_id) == "replacement-secret" + store.delete_webhook_integration(integration.id) with pytest.raises(KeyError): diff --git a/tests/unit/zen_stores/schemas/test_webhook_integration_schemas.py b/tests/unit/zen_stores/schemas/test_webhook_integration_schemas.py index 65c28646984..10f0923b4e1 100644 --- a/tests/unit/zen_stores/schemas/test_webhook_integration_schemas.py +++ b/tests/unit/zen_stores/schemas/test_webhook_integration_schemas.py @@ -88,9 +88,8 @@ def test_webhook_event_stats_update_rejects_invalid_outcome( }, ), (WebhookIntegrationRotateSecretRequest, {}), - (WebhookIntegrationUpdate, {}), ], - ids=["create", "rotate", "update"], + ids=["create", "rotate"], ) @pytest.mark.parametrize("secret", ["", " "]) def test_webhook_integration_models_reject_empty_secret( @@ -116,12 +115,9 @@ def test_webhook_integration_requests_allow_missing_secret() -> None: assert secret_request.secret is None -def test_webhook_integration_update_accepts_direct_secret() -> None: - """Webhook integration updates accept a direct signing secret.""" - update = WebhookIntegrationUpdate(secret="webhook-secret") - - assert update.secret is not None - assert update.secret.get_secret_value() == "webhook-secret" +def test_webhook_integration_update_excludes_secret() -> None: + """Webhook integration updates are limited to database fields.""" + assert "secret" not in WebhookIntegrationUpdate.model_fields def test_webhook_integration_schema_to_model_includes_body_and_metadata() -> ( From 0d8655c28e444763fc616d4a225a62dac5648f3d Mon Sep 17 00:00:00 2001 From: Iason Andriopoulos Date: Thu, 16 Jul 2026 14:30:47 +0300 Subject: [PATCH 18/18] Fix SQLite lock during connector secret deletion --- src/zenml/zen_stores/sql_zen_store.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/zenml/zen_stores/sql_zen_store.py b/src/zenml/zen_stores/sql_zen_store.py index 3d7cd573f05..e2edb74f4e0 100644 --- a/src/zenml/zen_stores/sql_zen_store.py +++ b/src/zenml/zen_stores/sql_zen_store.py @@ -9937,11 +9937,14 @@ def _delete_secret_schema(self, secret_id: UUID, session: Session) -> None: Raises: IllegalOperationError: If the secret is owned by a webhook. """ - webhook_id = session.exec( - select(WebhookIntegrationSchema.id).where( - WebhookIntegrationSchema.secret_id == secret_id - ) - ).first() + # Avoid flushing pending resource deletions before the secrets store, + # which may use a separate connection to the same SQLite database. + with session.no_autoflush: + webhook_id = session.exec( + select(WebhookIntegrationSchema.id).where( + WebhookIntegrationSchema.secret_id == secret_id + ) + ).first() if webhook_id is not None: raise IllegalOperationError( f"Cannot delete secret {secret_id}: it is owned by webhook "