diff --git a/docs/webhook_events.md b/docs/webhook_events.md index 5f908cd73d..1b31e159b6 100644 --- a/docs/webhook_events.md +++ b/docs/webhook_events.md @@ -21,6 +21,27 @@ Each event will be submitted via HTTP POST to the user provided URL. } ``` +## Event Grid Payload format + +If webhook is set to have Event Grid message format then the payload will look as follows: + +### Example + +```json +[ + { + "data": { + "ping_id": "00000000-0000-0000-0000-000000000000" + }, + "dataVersion": "1.0.0", + "eventTime": "0001-01-01T00:00:00", + "eventType": "ping", + "id": "00000000-0000-0000-0000-000000000000", + "subject": "example" + } +] +``` + ## Event Types (EventType) * [crash_reported](#crash_reported) diff --git a/docs/webhooks.md b/docs/webhooks.md index d041c80331..1396076746 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -26,6 +26,21 @@ $ onefuzz webhooks create MYWEBHOOK https://contoso.com/my-custom-webhook task_c $ ``` +Example creating a webhook subscription only the `task_created` events that produces webhook data in [Azure Event Grid](https://docs.microsoft.com/en-us/azure/event-grid/event-schema) compatible format: + +``` +$ onefuzz webhooks create MYWEBHOOK https://contoso.com/my-custom-webhook task_created --message_format event_grid +{ + "webhook_id": "cc6926de-7c6f-487e-96ec-7b632d3ed52b", + "name": "MYWEBHOOK", + "event_types": [ + "task_created" + ] +} +$ +``` + + ### Listing existing webhooks ``` diff --git a/src/api-service/__app__/onefuzzlib/webhooks.py b/src/api-service/__app__/onefuzzlib/webhooks.py index b92b277f4e..26a94c8f5a 100644 --- a/src/api-service/__app__/onefuzzlib/webhooks.py +++ b/src/api-service/__app__/onefuzzlib/webhooks.py @@ -5,6 +5,7 @@ import datetime import hmac +import json import logging from hashlib import sha512 from typing import List, Optional, Tuple @@ -16,7 +17,11 @@ from onefuzztypes.events import Event, EventMessage, EventPing, EventType from onefuzztypes.models import Error, Result from onefuzztypes.webhooks import Webhook as BASE_WEBHOOK -from onefuzztypes.webhooks import WebhookMessage +from onefuzztypes.webhooks import ( + WebhookMessage, + WebhookMessageEventGrid, + WebhookMessageFormat, +) from onefuzztypes.webhooks import WebhookMessageLog as BASE_WEBHOOK_MESSAGE_LOG from pydantic import BaseModel, Field @@ -203,6 +208,7 @@ def send(self, message_log: WebhookMessageLog) -> bool: event_type=message_log.event_type, event=message_log.event, secret_token=self.secret_token, + message_format=self.message_format, ) headers = {"Content-type": "application/json", "User-Agent": USER_AGENT} @@ -225,19 +231,36 @@ def build_message( event_type: EventType, event: Event, secret_token: Optional[str] = None, + message_format: Optional[WebhookMessageFormat] = None, ) -> Tuple[bytes, Optional[str]]: - data = ( - WebhookMessage( - webhook_id=webhook_id, - event_id=event_id, - event_type=event_type, - event=event, - instance_id=get_instance_id(), - instance_name=get_instance_name(), + + if message_format and message_format == WebhookMessageFormat.event_grid: + decoded = [ + json.loads( + WebhookMessageEventGrid( + id=event_id, + data=event, + dataVersion="1.0.0", + subject=get_instance_name(), + eventType=event_type, + eventTime=datetime.datetime.now(datetime.timezone.utc), + ).json(sort_keys=True, exclude_none=True) + ) + ] + data = json.dumps(decoded).encode() + else: + data = ( + WebhookMessage( + webhook_id=webhook_id, + event_id=event_id, + event_type=event_type, + event=event, + instance_id=get_instance_id(), + instance_name=get_instance_name(), + ) + .json(sort_keys=True, exclude_none=True) + .encode() ) - .json(sort_keys=True, exclude_none=True) - .encode() - ) digest = None if secret_token: digest = hmac.new(secret_token.encode(), msg=data, digestmod=sha512).hexdigest() diff --git a/src/api-service/__app__/webhooks/__init__.py b/src/api-service/__app__/webhooks/__init__.py index 5d3a74fc7f..02339811a9 100644 --- a/src/api-service/__app__/webhooks/__init__.py +++ b/src/api-service/__app__/webhooks/__init__.py @@ -51,6 +51,9 @@ def post(req: func.HttpRequest) -> func.HttpResponse: event_types=request.event_types, secret_token=request.secret_token, ) + if request.message_format is not None: + webhook.message_format = request.message_format + webhook.save() webhook.url = None @@ -83,6 +86,9 @@ def patch(req: func.HttpRequest) -> func.HttpResponse: if request.secret_token is not None: webhook.secret_token = request.secret_token + if request.message_format is not None: + webhook.message_format = request.message_format + webhook.save() webhook.url = None webhook.secret_token = None diff --git a/src/cli/onefuzz/api.py b/src/cli/onefuzz/api.py index 86122855fb..9307b2a404 100644 --- a/src/cli/onefuzz/api.py +++ b/src/cli/onefuzz/api.py @@ -326,6 +326,7 @@ def create( event_types: List[events.EventType], *, secret_token: Optional[str] = None, + message_format: Optional[webhooks.WebhookMessageFormat] = None, ) -> webhooks.Webhook: """Create a webhook""" self.logger.debug("creating webhook. name: %s", name) @@ -333,7 +334,11 @@ def create( "POST", webhooks.Webhook, data=requests.WebhookCreate( - name=name, url=url, event_types=event_types, secret_token=secret_token + name=name, + url=url, + event_types=event_types, + secret_token=secret_token, + message_format=message_format, ), ) @@ -345,6 +350,7 @@ def update( url: Optional[str] = None, event_types: Optional[List[events.EventType]] = None, secret_token: Optional[str] = None, + message_format: Optional[webhooks.WebhookMessageFormat] = None, ) -> webhooks.Webhook: """Update a webhook""" @@ -362,6 +368,7 @@ def update( url=url, event_types=event_types, secret_token=secret_token, + message_format=message_format, ), ) diff --git a/src/pytypes/extra/generate-docs.py b/src/pytypes/extra/generate-docs.py index f49e5a8f6a..e28caba490 100755 --- a/src/pytypes/extra/generate-docs.py +++ b/src/pytypes/extra/generate-docs.py @@ -3,6 +3,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +import datetime +import json import sys from typing import List, Optional from uuid import UUID @@ -65,7 +67,7 @@ UserInfo, ) from onefuzztypes.primitives import Container, PoolName, Region -from onefuzztypes.webhooks import WebhookMessage +from onefuzztypes.webhooks import WebhookMessage, WebhookMessageEventGrid EMPTY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" ZERO_SHA256 = "0" * len(EMPTY_SHA256) @@ -290,6 +292,25 @@ def main() -> None: instance_name="example", ) + message_event_grid = WebhookMessageEventGrid( + dataVersion="1.0.0", + subject="example", + eventType=EventType.ping, + eventTime=datetime.datetime.min, + id=UUID(int=0), + data=EventPing(ping_id=UUID(int=0)), + ) + + message_event_grid_json = json.dumps( + [ + json.loads( + message_event_grid.json(indent=4, exclude_none=True, sort_keys=True) + ) + ], + indent=4, + sort_keys=True, + ) + result = "" result += layer( 1, @@ -309,6 +330,21 @@ def main() -> None: message.json(indent=4, exclude_none=True, sort_keys=True), "json", ) + + result += layer( + 2, + "Event Grid Payload format", + "If webhook is set to have Event Grid message format then " + "the payload will look as follows:", + ) + + result += typed( + 3, + "Example", + message_event_grid_json, + "json", + ) + result += layer(2, "Event Types (EventType)") event_map = {get_event_type(x).name: x for x in examples} diff --git a/src/pytypes/onefuzztypes/requests.py b/src/pytypes/onefuzztypes/requests.py index fdfc1e0de2..dc558fffb0 100644 --- a/src/pytypes/onefuzztypes/requests.py +++ b/src/pytypes/onefuzztypes/requests.py @@ -22,6 +22,7 @@ from .events import EventType from .models import AutoScaleConfig, InstanceConfig, NotificationConfig from .primitives import Container, PoolName, Region +from .webhooks import WebhookMessageFormat class BaseRequest(BaseModel): @@ -211,6 +212,7 @@ class WebhookCreate(BaseRequest): url: AnyHttpUrl event_types: List[EventType] secret_token: Optional[str] + message_format: Optional[WebhookMessageFormat] class WebhookSearch(BaseModel): @@ -227,6 +229,7 @@ class WebhookUpdate(BaseModel): event_types: Optional[List[EventType]] url: Optional[AnyHttpUrl] secret_token: Optional[str] + message_format: Optional[WebhookMessageFormat] class NodeAddSshKey(BaseModel): diff --git a/src/pytypes/onefuzztypes/webhooks.py b/src/pytypes/onefuzztypes/webhooks.py index 1ad661bc38..3db9e87e18 100644 --- a/src/pytypes/onefuzztypes/webhooks.py +++ b/src/pytypes/onefuzztypes/webhooks.py @@ -3,19 +3,35 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +from datetime import datetime +from enum import Enum from typing import List, Optional from uuid import UUID, uuid4 from pydantic import AnyHttpUrl, BaseModel, Field from .enums import WebhookMessageState -from .events import EventMessage, EventType +from .events import Event, EventMessage, EventType + + +class WebhookMessageFormat(Enum): + onefuzz = "onefuzz" + event_grid = "event_grid" class WebhookMessage(EventMessage): webhook_id: UUID +class WebhookMessageEventGrid(BaseModel): + dataVersion: str + subject: str + eventType: EventType + eventTime: datetime + id: UUID + data: Event + + class WebhookMessageLog(WebhookMessage): state: WebhookMessageState = Field(default=WebhookMessageState.queued) try_count: int = Field(default=0) @@ -27,3 +43,4 @@ class Webhook(BaseModel): url: Optional[AnyHttpUrl] event_types: List[EventType] secret_token: Optional[str] + message_format: Optional[WebhookMessageFormat]