From b24d30261c056b66f295576f4363d4803f58917f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 5 Aug 2025 14:46:36 -0400 Subject: [PATCH] Closes #20003: Introduce mechanism to register callbacks for webhook context --- netbox/core/signals.py | 4 +-- netbox/extras/events.py | 19 ++++++----- netbox/extras/tests/test_event_rules.py | 24 ++++++++----- netbox/extras/webhooks.py | 34 +++++++++++++++++-- netbox/netbox/registry.py | 1 + netbox/netbox/tests/dummy_plugin/__init__.py | 2 +- .../tests/dummy_plugin/webhook_callbacks.py | 8 +++++ netbox/netbox/tests/test_plugins.py | 7 ++++ 8 files changed, 76 insertions(+), 23 deletions(-) create mode 100644 netbox/netbox/tests/dummy_plugin/webhook_callbacks.py diff --git a/netbox/core/signals.py b/netbox/core/signals.py index 0b8490dcb..dff5571ab 100644 --- a/netbox/core/signals.py +++ b/netbox/core/signals.py @@ -136,7 +136,7 @@ def handle_changed_object(sender, instance, **kwargs): # Enqueue the object for event processing queue = events_queue.get() - enqueue_event(queue, instance, request.user, request.id, event_type) + enqueue_event(queue, instance, request, event_type) events_queue.set(queue) # Increment metric counters @@ -220,7 +220,7 @@ def handle_deleted_object(sender, instance, **kwargs): # Enqueue the object for event processing queue = events_queue.get() - enqueue_event(queue, instance, request.user, request.id, OBJECT_DELETED) + enqueue_event(queue, instance, request, OBJECT_DELETED) events_queue.set(queue) # Increment metric counters diff --git a/netbox/extras/events.py b/netbox/extras/events.py index f8447fdb2..c2b08afa9 100644 --- a/netbox/extras/events.py +++ b/netbox/extras/events.py @@ -14,6 +14,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT from netbox.models.features import has_feature from users.models import User from utilities.api import get_serializer_for_model +from utilities.request import copy_safe_request from utilities.rqworker import get_rq_retry from utilities.serialization import serialize_object from .choices import EventRuleActionChoices @@ -50,7 +51,7 @@ def get_snapshots(instance, event_type): return snapshots -def enqueue_event(queue, instance, user, request_id, event_type): +def enqueue_event(queue, instance, request, event_type): """ Enqueue a serialized representation of a created/updated/deleted object for the processing of events once the request has completed. @@ -77,12 +78,14 @@ def enqueue_event(queue, instance, user, request_id, event_type): 'event_type': event_type, 'data': serialize_for_event(instance), 'snapshots': get_snapshots(instance, event_type), - 'username': user.username, - 'request_id': request_id + 'request': request, + # Legacy request attributes for backward compatibility + 'username': request.user.username, + 'request_id': request.id, } -def process_event_rules(event_rules, object_type, event_type, data, username=None, snapshots=None, request_id=None): +def process_event_rules(event_rules, object_type, event_type, data, username=None, snapshots=None, request=None): user = User.objects.get(username=username) if username else None for event_rule in event_rules: @@ -105,7 +108,7 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non # Compile the task parameters params = { "event_rule": event_rule, - "model_name": object_type.model, + "object_type": object_type, "event_type": event_type, "data": event_data, "snapshots": snapshots, @@ -115,8 +118,8 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non } if snapshots: params["snapshots"] = snapshots - if request_id: - params["request_id"] = request_id + if request: + params["request"] = copy_safe_request(request) # Enqueue the task rq_queue.enqueue( @@ -180,7 +183,7 @@ def process_event_queue(events): data=event['data'], username=event['username'], snapshots=event['snapshots'], - request_id=event['request_id'] + request=event['request'], ) diff --git a/netbox/extras/tests/test_event_rules.py b/netbox/extras/tests/test_event_rules.py index 2565e5bde..04d0b183f 100644 --- a/netbox/extras/tests/test_event_rules.py +++ b/netbox/extras/tests/test_event_rules.py @@ -3,6 +3,7 @@ import uuid from unittest.mock import patch import django_rq +from django.contrib.contenttypes.models import ContentType from django.http import HttpResponse from django.test import RequestFactory from django.urls import reverse @@ -135,7 +136,7 @@ class EventRuleTest(APITestCase): job = self.queue.jobs[0] self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(name='Event Rule 1')) self.assertEqual(job.kwargs['event_type'], OBJECT_CREATED) - self.assertEqual(job.kwargs['model_name'], 'site') + self.assertEqual(job.kwargs['object_type'], ContentType.objects.get_for_model(Site)) self.assertEqual(job.kwargs['data']['id'], response.data['id']) self.assertEqual(job.kwargs['data']['foo'], 1) self.assertEqual(len(job.kwargs['data']['tags']), len(response.data['tags'])) @@ -186,7 +187,7 @@ class EventRuleTest(APITestCase): for i, job in enumerate(self.queue.jobs): self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(name='Event Rule 1')) self.assertEqual(job.kwargs['event_type'], OBJECT_CREATED) - self.assertEqual(job.kwargs['model_name'], 'site') + self.assertEqual(job.kwargs['object_type'], ContentType.objects.get_for_model(Site)) self.assertEqual(job.kwargs['data']['id'], response.data[i]['id']) self.assertEqual(job.kwargs['data']['foo'], 1) self.assertEqual(len(job.kwargs['data']['tags']), len(response.data[i]['tags'])) @@ -218,7 +219,7 @@ class EventRuleTest(APITestCase): job = self.queue.jobs[0] self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(name='Event Rule 2')) self.assertEqual(job.kwargs['event_type'], OBJECT_UPDATED) - self.assertEqual(job.kwargs['model_name'], 'site') + self.assertEqual(job.kwargs['object_type'], ContentType.objects.get_for_model(Site)) self.assertEqual(job.kwargs['data']['id'], site.pk) self.assertEqual(job.kwargs['data']['foo'], 2) self.assertEqual(len(job.kwargs['data']['tags']), len(response.data['tags'])) @@ -275,7 +276,7 @@ class EventRuleTest(APITestCase): for i, job in enumerate(self.queue.jobs): self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(name='Event Rule 2')) self.assertEqual(job.kwargs['event_type'], OBJECT_UPDATED) - self.assertEqual(job.kwargs['model_name'], 'site') + self.assertEqual(job.kwargs['object_type'], ContentType.objects.get_for_model(Site)) self.assertEqual(job.kwargs['data']['id'], data[i]['id']) self.assertEqual(job.kwargs['data']['foo'], 2) self.assertEqual(len(job.kwargs['data']['tags']), len(response.data[i]['tags'])) @@ -302,7 +303,7 @@ class EventRuleTest(APITestCase): job = self.queue.jobs[0] self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(name='Event Rule 3')) self.assertEqual(job.kwargs['event_type'], OBJECT_DELETED) - self.assertEqual(job.kwargs['model_name'], 'site') + self.assertEqual(job.kwargs['object_type'], ContentType.objects.get_for_model(Site)) self.assertEqual(job.kwargs['data']['id'], site.pk) self.assertEqual(job.kwargs['data']['foo'], 3) self.assertEqual(job.kwargs['snapshots']['prechange']['name'], 'Site 1') @@ -336,7 +337,7 @@ class EventRuleTest(APITestCase): for i, job in enumerate(self.queue.jobs): self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(name='Event Rule 3')) self.assertEqual(job.kwargs['event_type'], OBJECT_DELETED) - self.assertEqual(job.kwargs['model_name'], 'site') + self.assertEqual(job.kwargs['object_type'], ContentType.objects.get_for_model(Site)) self.assertEqual(job.kwargs['data']['id'], sites[i].pk) self.assertEqual(job.kwargs['data']['foo'], 3) self.assertEqual(job.kwargs['snapshots']['prechange']['name'], sites[i].name) @@ -368,18 +369,23 @@ class EventRuleTest(APITestCase): self.assertEqual(body['request_id'], str(request_id)) self.assertEqual(body['data']['name'], 'Site 1') self.assertEqual(body['data']['foo'], 1) + self.assertEqual(body['context']['foo'], 123) # From netbox.tests.dummy_plugin return HttpResponse() + # Create a dummy request + request = RequestFactory().get(reverse('dcim:site_add')) + request.id = request_id + request.user = self.user + # Enqueue a webhook for processing webhooks_queue = {} site = Site.objects.create(name='Site 1', slug='site-1') enqueue_event( webhooks_queue, instance=site, - user=self.user, - request_id=request_id, - event_type=OBJECT_CREATED + request=request, + event_type=OBJECT_CREATED, ) flush_events(list(webhooks_queue.values())) diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index 368075217..1620d950e 100644 --- a/netbox/extras/webhooks.py +++ b/netbox/extras/webhooks.py @@ -6,12 +6,28 @@ import requests from django_rq import job from jinja2.exceptions import TemplateError +from netbox.registry import registry from utilities.proxy import resolve_proxies from .constants import WEBHOOK_EVENT_TYPES +__all__ = ( + 'generate_signature', + 'register_webhook_callback', + 'send_webhook', +) + logger = logging.getLogger('netbox.webhooks') +def register_webhook_callback(func): + """ + Register a function as a webhook callback. + """ + registry['webhook_callbacks'].append(func) + logger.debug(f'Registered webhook callback {func.__module__}.{func.__name__}') + return func + + def generate_signature(request_body, secret): """ Return a cryptographic signature that can be used to verify the authenticity of webhook data. @@ -25,7 +41,7 @@ def generate_signature(request_body, secret): @job('default') -def send_webhook(event_rule, model_name, event_type, data, timestamp, username, request_id=None, snapshots=None): +def send_webhook(event_rule, object_type, event_type, data, timestamp, username, request=None, snapshots=None): """ Make a POST request to the defined Webhook """ @@ -35,9 +51,9 @@ def send_webhook(event_rule, model_name, event_type, data, timestamp, username, context = { 'event': WEBHOOK_EVENT_TYPES.get(event_type, event_type), 'timestamp': timestamp, - 'model': model_name, + 'model': object_type.model, 'username': username, - 'request_id': request_id, + 'request_id': request.id if request else None, 'data': data, } if snapshots: @@ -45,6 +61,18 @@ def send_webhook(event_rule, model_name, event_type, data, timestamp, username, 'snapshots': snapshots }) + # Add any additional context from plugins + callback_data = {} + for callback in registry['webhook_callbacks']: + try: + if ret := callback(object_type, event_type, data, request): + callback_data.update(**ret) + except Exception as e: + logger.warning(f"Caught exception when processing callback {callback}: {e}") + pass + if callback_data: + context['context'] = callback_data + # Build the headers for the HTTP request headers = { 'Content-Type': webhook.http_content_type, diff --git a/netbox/netbox/registry.py b/netbox/netbox/registry.py index 02b741779..fe5ce4301 100644 --- a/netbox/netbox/registry.py +++ b/netbox/netbox/registry.py @@ -34,5 +34,6 @@ registry = Registry({ 'system_jobs': dict(), 'tables': collections.defaultdict(dict), 'views': collections.defaultdict(dict), + 'webhook_callbacks': list(), 'widgets': dict(), }) diff --git a/netbox/netbox/tests/dummy_plugin/__init__.py b/netbox/netbox/tests/dummy_plugin/__init__.py index 2ca7c290c..01eb0baa5 100644 --- a/netbox/netbox/tests/dummy_plugin/__init__.py +++ b/netbox/netbox/tests/dummy_plugin/__init__.py @@ -24,7 +24,7 @@ class DummyPluginConfig(PluginConfig): def ready(self): super().ready() - from . import jobs # noqa: F401 + from . import jobs, webhook_callbacks # noqa: F401 config = DummyPluginConfig diff --git a/netbox/netbox/tests/dummy_plugin/webhook_callbacks.py b/netbox/netbox/tests/dummy_plugin/webhook_callbacks.py new file mode 100644 index 000000000..095f545b5 --- /dev/null +++ b/netbox/netbox/tests/dummy_plugin/webhook_callbacks.py @@ -0,0 +1,8 @@ +from extras.webhooks import register_webhook_callback + + +@register_webhook_callback +def set_context(object_type, event_type, data, request): + return { + 'foo': 123, + } diff --git a/netbox/netbox/tests/test_plugins.py b/netbox/netbox/tests/test_plugins.py index 9f2033936..550dca514 100644 --- a/netbox/netbox/tests/test_plugins.py +++ b/netbox/netbox/tests/test_plugins.py @@ -10,6 +10,7 @@ from core.models import ObjectType from netbox.tests.dummy_plugin import config as dummy_config from netbox.tests.dummy_plugin.data_backends import DummyBackend from netbox.tests.dummy_plugin.jobs import DummySystemJob +from netbox.tests.dummy_plugin.webhook_callbacks import set_context from netbox.plugins.navigation import PluginMenu from netbox.plugins.utils import get_plugin_config from netbox.graphql.schema import Query @@ -220,3 +221,9 @@ class PluginTest(TestCase): Check that events pipeline is registered. """ self.assertIn('netbox.tests.dummy_plugin.events.process_events_queue', settings.EVENTS_PIPELINE) + + def test_webhook_callbacks(self): + """ + Test the registration of webhook callbacks. + """ + self.assertIn(set_context, registry['webhook_callbacks'])