From 7e26d921901077522a6f0fa4c4755c26280a1a7d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 22 Oct 2021 16:27:08 -0400 Subject: [PATCH 1/6] Introduce conditions & condition sets --- netbox/extras/conditions.py | 122 +++++++++++++++++++ netbox/extras/tests/test_conditions.py | 160 +++++++++++++++++++++++++ 2 files changed, 282 insertions(+) create mode 100644 netbox/extras/conditions.py create mode 100644 netbox/extras/tests/test_conditions.py diff --git a/netbox/extras/conditions.py b/netbox/extras/conditions.py new file mode 100644 index 000000000..6aa6e776f --- /dev/null +++ b/netbox/extras/conditions.py @@ -0,0 +1,122 @@ +import functools + +__all__ = ( + 'Condition', + 'ConditionSet', +) + + +LOGIC_TYPES = ( + 'and', + 'or' +) + + +def is_ruleset(data): + """ + Determine whether the given dictionary looks like a rule set. + """ + return type(data) is dict and len(data) == 1 and list(data.keys())[0] in LOGIC_TYPES + + +class Condition: + """ + An individual conditional rule that evaluates a single attribute and its value. + + :param attr: The name of the attribute being evaluated + :param value: The value being compared + :param op: The logical operation to use when evaluating the value (default: 'eq') + """ + EQ = 'eq' + NEQ = 'neq' + GT = 'gt' + GTE = 'gte' + LT = 'lt' + LTE = 'lte' + IN = 'in' + CONTAINS = 'contains' + + OPERATORS = ( + EQ, NEQ, GT, GTE, LT, LTE, IN, CONTAINS + ) + + def __init__(self, attr, value, op=EQ): + self.attr = attr + self.value = value + if op not in self.OPERATORS: + raise ValueError(f"Unknown operator: {op}") + self.eval_func = getattr(self, f'eval_{op}') + + def eval(self, data): + """ + Evaluate the provided data to determine whether it matches the condition. + """ + value = functools.reduce(dict.get, self.attr.split('.'), data) + return self.eval_func(value) + + # Equivalency + + def eval_eq(self, value): + return value == self.value + + def eval_neq(self, value): + return value != self.value + + # Numeric comparisons + + def eval_gt(self, value): + return value > self.value + + def eval_gte(self, value): + return value >= self.value + + def eval_lt(self, value): + return value < self.value + + def eval_lte(self, value): + return value <= self.value + + # Membership + + def eval_in(self, value): + return value in self.value + + def eval_contains(self, value): + return self.value in value + + +class ConditionSet: + """ + A set of one or more Condition to be evaluated per the prescribed logic (AND or OR). Example: + + {"and": [ + {"attr": "foo", "op": "eq", "value": 1}, + {"attr": "bar", "op": "neq", "value": 2} + ]} + + :param ruleset: A dictionary mapping a logical operator to a list of conditional rules + """ + def __init__(self, ruleset): + if type(ruleset) is not dict: + raise ValueError(f"Ruleset must be a dictionary, not {type(ruleset)}.") + if len(ruleset) != 1: + raise ValueError(f"Ruleset must have exactly one logical operator (found {len(ruleset)})") + + # Determine the logic type + logic = list(ruleset.keys())[0] + if type(logic) is not str or logic.lower() not in LOGIC_TYPES: + raise ValueError(f"Invalid logic type: {logic} (must be 'and' or 'or')") + self.logic = logic.lower() + + # Compile the set of Conditions + self.conditions = [ + ConditionSet(rule) if is_ruleset(rule) else Condition(**rule) + for rule in ruleset[self.logic] + ] + + def eval(self, data): + """ + Evaluate the provided data to determine whether it matches this set of conditions. + """ + func = any if self.logic == 'or' else all + return func(d.eval(data) for d in self.conditions) diff --git a/netbox/extras/tests/test_conditions.py b/netbox/extras/tests/test_conditions.py new file mode 100644 index 000000000..7defca5b5 --- /dev/null +++ b/netbox/extras/tests/test_conditions.py @@ -0,0 +1,160 @@ +from django.test import TestCase + +from extras.conditions import Condition, ConditionSet + + +class ConditionTestCase(TestCase): + + def test_dotted_path_access(self): + c = Condition('a.b.c', 1, 'eq') + self.assertTrue(c.eval({'a': {'b': {'c': 1}}})) + self.assertFalse(c.eval({'a': {'b': {'c': 2}}})) + self.assertFalse(c.eval({'a': {'b': {'x': 1}}})) + + def test_undefined_attr(self): + c = Condition('x', 1, 'eq') + self.assertFalse(c.eval({})) + self.assertTrue(c.eval({'x': 1})) + + # + # Operator tests + # + + def test_default_operator(self): + c = Condition('x', 1) + self.assertEqual(c.eval_func, c.eval_eq) + + def test_eq(self): + c = Condition('x', 1, 'eq') + self.assertTrue(c.eval({'x': 1})) + self.assertFalse(c.eval({'x': 2})) + + def test_neq(self): + c = Condition('x', 1, 'neq') + self.assertFalse(c.eval({'x': 1})) + self.assertTrue(c.eval({'x': 2})) + + def test_gt(self): + c = Condition('x', 1, 'gt') + self.assertTrue(c.eval({'x': 2})) + self.assertFalse(c.eval({'x': 1})) + + def test_gte(self): + c = Condition('x', 1, 'gte') + self.assertTrue(c.eval({'x': 2})) + self.assertTrue(c.eval({'x': 1})) + self.assertFalse(c.eval({'x': 0})) + + def test_lt(self): + c = Condition('x', 2, 'lt') + self.assertTrue(c.eval({'x': 1})) + self.assertFalse(c.eval({'x': 2})) + + def test_lte(self): + c = Condition('x', 2, 'lte') + self.assertTrue(c.eval({'x': 1})) + self.assertTrue(c.eval({'x': 2})) + self.assertFalse(c.eval({'x': 3})) + + def test_in(self): + c = Condition('x', [1, 2, 3], 'in') + self.assertTrue(c.eval({'x': 1})) + self.assertFalse(c.eval({'x': 9})) + + def test_contains(self): + c = Condition('x', 1, 'contains') + self.assertTrue(c.eval({'x': [1, 2, 3]})) + self.assertFalse(c.eval({'x': [2, 3, 4]})) + + +class ConditionSetTest(TestCase): + + def test_empty(self): + with self.assertRaises(ValueError): + ConditionSet({}) + + def test_invalid_logic(self): + with self.assertRaises(ValueError): + ConditionSet({'foo': []}) + + def test_and_single_depth(self): + cs = ConditionSet({ + 'and': [ + {'attr': 'a', 'value': 1, 'op': 'eq'}, + {'attr': 'b', 'value': 2, 'op': 'eq'}, + ] + }) + self.assertTrue(cs.eval({'a': 1, 'b': 2})) + self.assertFalse(cs.eval({'a': 1, 'b': 3})) + + def test_or_single_depth(self): + cs = ConditionSet({ + 'or': [ + {'attr': 'a', 'value': 1, 'op': 'eq'}, + {'attr': 'b', 'value': 1, 'op': 'eq'}, + ] + }) + self.assertTrue(cs.eval({'a': 1, 'b': 2})) + self.assertTrue(cs.eval({'a': 2, 'b': 1})) + self.assertFalse(cs.eval({'a': 2, 'b': 2})) + + def test_and_multi_depth(self): + cs = ConditionSet({ + 'and': [ + {'attr': 'a', 'value': 1, 'op': 'eq'}, + {'and': [ + {'attr': 'b', 'value': 2, 'op': 'eq'}, + {'attr': 'c', 'value': 3, 'op': 'eq'}, + ]} + ] + }) + self.assertTrue(cs.eval({'a': 1, 'b': 2, 'c': 3})) + self.assertFalse(cs.eval({'a': 9, 'b': 2, 'c': 3})) + self.assertFalse(cs.eval({'a': 1, 'b': 9, 'c': 3})) + self.assertFalse(cs.eval({'a': 1, 'b': 2, 'c': 9})) + + def test_or_multi_depth(self): + cs = ConditionSet({ + 'or': [ + {'attr': 'a', 'value': 1, 'op': 'eq'}, + {'or': [ + {'attr': 'b', 'value': 2, 'op': 'eq'}, + {'attr': 'c', 'value': 3, 'op': 'eq'}, + ]} + ] + }) + self.assertTrue(cs.eval({'a': 1, 'b': 9, 'c': 9})) + self.assertTrue(cs.eval({'a': 9, 'b': 2, 'c': 9})) + self.assertTrue(cs.eval({'a': 9, 'b': 9, 'c': 3})) + self.assertFalse(cs.eval({'a': 9, 'b': 9, 'c': 9})) + + def test_mixed_and(self): + cs = ConditionSet({ + 'and': [ + {'attr': 'a', 'value': 1, 'op': 'eq'}, + {'or': [ + {'attr': 'b', 'value': 2, 'op': 'eq'}, + {'attr': 'c', 'value': 3, 'op': 'eq'}, + ]} + ] + }) + self.assertTrue(cs.eval({'a': 1, 'b': 2, 'c': 9})) + self.assertTrue(cs.eval({'a': 1, 'b': 9, 'c': 3})) + self.assertFalse(cs.eval({'a': 1, 'b': 9, 'c': 9})) + self.assertFalse(cs.eval({'a': 9, 'b': 2, 'c': 3})) + + def test_mixed_or(self): + cs = ConditionSet({ + 'or': [ + {'attr': 'a', 'value': 1, 'op': 'eq'}, + {'and': [ + {'attr': 'b', 'value': 2, 'op': 'eq'}, + {'attr': 'c', 'value': 3, 'op': 'eq'}, + ]} + ] + }) + self.assertTrue(cs.eval({'a': 1, 'b': 9, 'c': 9})) + self.assertTrue(cs.eval({'a': 9, 'b': 2, 'c': 3})) + self.assertTrue(cs.eval({'a': 1, 'b': 2, 'c': 9})) + self.assertFalse(cs.eval({'a': 9, 'b': 2, 'c': 9})) + self.assertFalse(cs.eval({'a': 9, 'b': 9, 'c': 3})) From 78ecc8673ca71bc6db3dddf065ca4203bc224740 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 22 Oct 2021 17:15:08 -0400 Subject: [PATCH 2/6] Add conditions for webhooks --- netbox/extras/api/serializers.py | 2 +- netbox/extras/forms/bulk_edit.py | 2 +- netbox/extras/forms/models.py | 1 + .../migrations/0063_webhook_conditions.py | 18 +++++++++++++ netbox/extras/models/models.py | 18 ++++++++++--- netbox/extras/tests/test_views.py | 1 + netbox/extras/webhooks_worker.py | 27 ++++++++++--------- 7 files changed, 51 insertions(+), 18 deletions(-) create mode 100644 netbox/extras/migrations/0063_webhook_conditions.py diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index b2049e836..46d295195 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -61,7 +61,7 @@ class WebhookSerializer(ValidatedModelSerializer): fields = [ 'id', 'url', 'display', 'content_types', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url', 'enabled', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', - 'ssl_verification', 'ca_file_path', + 'conditions', 'ssl_verification', 'ca_file_path', ] diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index b85a74a5b..937814c5a 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -137,7 +137,7 @@ class WebhookBulkEditForm(BootstrapMixin, BulkEditForm): ) class Meta: - nullable_fields = ['secret', 'ca_file_path'] + nullable_fields = ['secret', 'conditions', 'ca_file_path'] class TagBulkEditForm(BootstrapMixin, BulkEditForm): diff --git a/netbox/extras/forms/models.py b/netbox/extras/forms/models.py index 7e462e62b..23f4872c2 100644 --- a/netbox/extras/forms/models.py +++ b/netbox/extras/forms/models.py @@ -102,6 +102,7 @@ class WebhookForm(BootstrapMixin, forms.ModelForm): ('HTTP Request', ( 'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', )), + ('Conditions', ('conditions',)), ('SSL', ('ssl_verification', 'ca_file_path')), ) widgets = { diff --git a/netbox/extras/migrations/0063_webhook_conditions.py b/netbox/extras/migrations/0063_webhook_conditions.py new file mode 100644 index 000000000..8cc5b1bd3 --- /dev/null +++ b/netbox/extras/migrations/0063_webhook_conditions.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.8 on 2021-10-22 20:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0062_clear_secrets_changelog'), + ] + + operations = [ + migrations.AddField( + model_name='webhook', + name='conditions', + field=models.JSONField(blank=True, null=True), + ), + ] diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 75f5242d3..43af19f82 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -9,11 +9,12 @@ from django.db import models from django.http import HttpResponse from django.urls import reverse from django.utils import timezone -from django.utils.formats import date_format, time_format +from django.utils.formats import date_format from rest_framework.utils.encoders import JSONEncoder from extras.choices import * from extras.constants import * +from extras.conditions import ConditionSet from extras.utils import extras_features, FeatureQuery, image_upload from netbox.models import BigIDModel, ChangeLoggedModel from utilities.querysets import RestrictedQuerySet @@ -107,6 +108,11 @@ class Webhook(ChangeLoggedModel): "the secret as the key. The secret is not transmitted in " "the request." ) + conditions = models.JSONField( + blank=True, + null=True, + help_text="A set of conditions which determine whether the webhook will be generated." + ) ssl_verification = models.BooleanField( default=True, verbose_name='SSL verification', @@ -138,9 +144,13 @@ class Webhook(ChangeLoggedModel): # At least one action type must be selected if not self.type_create and not self.type_delete and not self.type_update: - raise ValidationError( - "You must select at least one type: create, update, and/or delete." - ) + raise ValidationError("At least one type must be selected: create, update, and/or delete.") + + if self.conditions: + try: + ConditionSet(self.conditions) + except ValueError as e: + raise ValidationError({'conditions': e}) # CA file path requires SSL verification enabled if not self.ssl_verification and self.ca_file_path: diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 72d965fd0..9ce324a5c 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -145,6 +145,7 @@ class WebhookTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'payload_url': 'http://example.com/?x', 'http_method': 'GET', 'http_content_type': 'application/foo', + 'conditions': None, } cls.csv_data = ( diff --git a/netbox/extras/webhooks_worker.py b/netbox/extras/webhooks_worker.py index ce63e14ce..6bbfba907 100644 --- a/netbox/extras/webhooks_worker.py +++ b/netbox/extras/webhooks_worker.py @@ -6,6 +6,7 @@ from django_rq import job from jinja2.exceptions import TemplateError from .choices import ObjectChangeActionChoices +from .conditions import ConditionSet from .webhooks import generate_signature logger = logging.getLogger('netbox.webhooks_worker') @@ -16,6 +17,12 @@ def process_webhook(webhook, model_name, event, data, snapshots, timestamp, user """ Make a POST request to the defined Webhook """ + # Evaluate webhook conditions (if any) + if webhook.conditions: + if not ConditionSet(webhook.conditions).eval(data): + return + + # Prepare context data for headers & body templates context = { 'event': dict(ObjectChangeActionChoices)[event].lower(), 'timestamp': timestamp, @@ -33,14 +40,14 @@ def process_webhook(webhook, model_name, event, data, snapshots, timestamp, user try: headers.update(webhook.render_headers(context)) except (TemplateError, ValueError) as e: - logger.error("Error parsing HTTP headers for webhook {}: {}".format(webhook, e)) + logger.error(f"Error parsing HTTP headers for webhook {webhook}: {e}") raise e # Render the request body try: body = webhook.render_body(context) except TemplateError as e: - logger.error("Error rendering request body for webhook {}: {}".format(webhook, e)) + logger.error(f"Error rendering request body for webhook {webhook}: {e}") raise e # Prepare the HTTP request @@ -51,15 +58,13 @@ def process_webhook(webhook, model_name, event, data, snapshots, timestamp, user 'data': body.encode('utf8'), } logger.info( - "Sending {} request to {} ({} {})".format( - params['method'], params['url'], context['model'], context['event'] - ) + f"Sending {params['method']} request to {params['url']} ({context['model']} {context['event']})" ) logger.debug(params) try: prepared_request = requests.Request(**params).prepare() except requests.exceptions.RequestException as e: - logger.error("Error forming HTTP request: {}".format(e)) + logger.error(f"Error forming HTTP request: {e}") raise e # If a secret key is defined, sign the request with a hash of the key and its content @@ -74,12 +79,10 @@ def process_webhook(webhook, model_name, event, data, snapshots, timestamp, user response = session.send(prepared_request, proxies=settings.HTTP_PROXIES) if 200 <= response.status_code <= 299: - logger.info("Request succeeded; response status {}".format(response.status_code)) - return 'Status {} returned, webhook successfully processed.'.format(response.status_code) + logger.info(f"Request succeeded; response status {response.status_code}") + return f"Status {response.status_code} returned, webhook successfully processed." else: - logger.warning("Request failed; response status {}: {}".format(response.status_code, response.content)) + logger.warning(f"Request failed; response status {response.status_code}: {response.content}") raise requests.exceptions.RequestException( - "Status {} returned with content '{}', webhook FAILED to process.".format( - response.status_code, response.content - ) + f"Status {response.status_code} returned with content '{response.content}', webhook FAILED to process." ) From b92de63245e186b54b8507e11804ffc1e275ce42 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 25 Oct 2021 08:56:20 -0400 Subject: [PATCH 3/6] Improve validation --- netbox/extras/conditions.py | 17 +++++++++++++++-- netbox/extras/tests/test_conditions.py | 19 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/netbox/extras/conditions.py b/netbox/extras/conditions.py index 6aa6e776f..050d5564c 100644 --- a/netbox/extras/conditions.py +++ b/netbox/extras/conditions.py @@ -40,11 +40,24 @@ class Condition: EQ, NEQ, GT, GTE, LT, LTE, IN, CONTAINS ) + TYPES = { + str: (EQ, NEQ, CONTAINS), + bool: (EQ, NEQ, CONTAINS), + int: (EQ, NEQ, GT, GTE, LT, LTE, CONTAINS), + float: (EQ, NEQ, GT, GTE, LT, LTE, CONTAINS), + list: (EQ, NEQ, IN, CONTAINS) + } + def __init__(self, attr, value, op=EQ): + if op not in self.OPERATORS: + raise ValueError(f"Unknown operator: {op}. Must be one of: {', '.join(self.OPERATORS)}") + if type(value) not in self.TYPES: + raise ValueError(f"Unsupported value type: {type(value)}") + if op not in self.TYPES[type(value)]: + raise ValueError(f"Invalid type for {op} operation: {type(value)}") + self.attr = attr self.value = value - if op not in self.OPERATORS: - raise ValueError(f"Unknown operator: {op}") self.eval_func = getattr(self, f'eval_{op}') def eval(self, data): diff --git a/netbox/extras/tests/test_conditions.py b/netbox/extras/tests/test_conditions.py index 7defca5b5..2ce55c064 100644 --- a/netbox/extras/tests/test_conditions.py +++ b/netbox/extras/tests/test_conditions.py @@ -16,6 +16,25 @@ class ConditionTestCase(TestCase): self.assertFalse(c.eval({})) self.assertTrue(c.eval({'x': 1})) + # + # Validation tests + # + + def test_invalid_op(self): + with self.assertRaises(ValueError): + # 'blah' is not a valid operator + Condition('x', 1, 'blah') + + def test_invalid_type(self): + with self.assertRaises(ValueError): + # dict type is unsupported + Condition('x', 1, dict()) + + def test_invalid_op_type(self): + with self.assertRaises(ValueError): + # 'gt' supports only numeric values + Condition('x', 'foo', 'gt') + # # Operator tests # From 35c967e6f72ebdc4edb3be7d3737aa0912469688 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 25 Oct 2021 09:09:51 -0400 Subject: [PATCH 4/6] Implement condition negation --- netbox/extras/conditions.py | 36 ++++++++++++++------------ netbox/extras/tests/test_conditions.py | 18 ++++++++++--- 2 files changed, 33 insertions(+), 21 deletions(-) diff --git a/netbox/extras/conditions.py b/netbox/extras/conditions.py index 050d5564c..7f1d804e8 100644 --- a/netbox/extras/conditions.py +++ b/netbox/extras/conditions.py @@ -6,17 +6,15 @@ __all__ = ( ) -LOGIC_TYPES = ( - 'and', - 'or' -) +AND = 'and' +OR = 'or' def is_ruleset(data): """ Determine whether the given dictionary looks like a rule set. """ - return type(data) is dict and len(data) == 1 and list(data.keys())[0] in LOGIC_TYPES + return type(data) is dict and len(data) == 1 and list(data.keys())[0] in (AND, OR) class Condition: @@ -28,7 +26,6 @@ class Condition: :param op: The logical operation to use when evaluating the value (default: 'eq') """ EQ = 'eq' - NEQ = 'neq' GT = 'gt' GTE = 'gte' LT = 'lt' @@ -37,18 +34,18 @@ class Condition: CONTAINS = 'contains' OPERATORS = ( - EQ, NEQ, GT, GTE, LT, LTE, IN, CONTAINS + EQ, GT, GTE, LT, LTE, IN, CONTAINS ) TYPES = { - str: (EQ, NEQ, CONTAINS), - bool: (EQ, NEQ, CONTAINS), - int: (EQ, NEQ, GT, GTE, LT, LTE, CONTAINS), - float: (EQ, NEQ, GT, GTE, LT, LTE, CONTAINS), - list: (EQ, NEQ, IN, CONTAINS) + str: (EQ, CONTAINS), + bool: (EQ, CONTAINS), + int: (EQ, GT, GTE, LT, LTE, CONTAINS), + float: (EQ, GT, GTE, LT, LTE, CONTAINS), + list: (EQ, IN, CONTAINS) } - def __init__(self, attr, value, op=EQ): + def __init__(self, attr, value, op=EQ, negate=False): if op not in self.OPERATORS: raise ValueError(f"Unknown operator: {op}. Must be one of: {', '.join(self.OPERATORS)}") if type(value) not in self.TYPES: @@ -59,13 +56,18 @@ class Condition: self.attr = attr self.value = value self.eval_func = getattr(self, f'eval_{op}') + self.negate = negate def eval(self, data): """ Evaluate the provided data to determine whether it matches the condition. """ value = functools.reduce(dict.get, self.attr.split('.'), data) - return self.eval_func(value) + result = self.eval_func(value) + + if self.negate: + return not result + return result # Equivalency @@ -104,7 +106,7 @@ class ConditionSet: {"and": [ {"attr": "foo", "op": "eq", "value": 1}, - {"attr": "bar", "op": "neq", "value": 2} + {"attr": "bar", "op": "eq", "value": 2, "negate": true} ]} :param ruleset: A dictionary mapping a logical operator to a list of conditional rules @@ -117,8 +119,8 @@ class ConditionSet: # Determine the logic type logic = list(ruleset.keys())[0] - if type(logic) is not str or logic.lower() not in LOGIC_TYPES: - raise ValueError(f"Invalid logic type: {logic} (must be 'and' or 'or')") + if type(logic) is not str or logic.lower() not in (AND, OR): + raise ValueError(f"Invalid logic type: {logic} (must be '{AND}' or '{OR}')") self.logic = logic.lower() # Compile the set of Conditions diff --git a/netbox/extras/tests/test_conditions.py b/netbox/extras/tests/test_conditions.py index 2ce55c064..47ae0b662 100644 --- a/netbox/extras/tests/test_conditions.py +++ b/netbox/extras/tests/test_conditions.py @@ -48,8 +48,8 @@ class ConditionTestCase(TestCase): self.assertTrue(c.eval({'x': 1})) self.assertFalse(c.eval({'x': 2})) - def test_neq(self): - c = Condition('x', 1, 'neq') + def test_eq_negated(self): + c = Condition('x', 1, 'eq', negate=True) self.assertFalse(c.eval({'x': 1})) self.assertTrue(c.eval({'x': 2})) @@ -80,11 +80,21 @@ class ConditionTestCase(TestCase): self.assertTrue(c.eval({'x': 1})) self.assertFalse(c.eval({'x': 9})) + def test_in_negated(self): + c = Condition('x', [1, 2, 3], 'in', negate=True) + self.assertFalse(c.eval({'x': 1})) + self.assertTrue(c.eval({'x': 9})) + def test_contains(self): c = Condition('x', 1, 'contains') self.assertTrue(c.eval({'x': [1, 2, 3]})) self.assertFalse(c.eval({'x': [2, 3, 4]})) + def test_contains_negated(self): + c = Condition('x', 1, 'contains', negate=True) + self.assertFalse(c.eval({'x': [1, 2, 3]})) + self.assertTrue(c.eval({'x': [2, 3, 4]})) + class ConditionSetTest(TestCase): @@ -100,11 +110,11 @@ class ConditionSetTest(TestCase): cs = ConditionSet({ 'and': [ {'attr': 'a', 'value': 1, 'op': 'eq'}, - {'attr': 'b', 'value': 2, 'op': 'eq'}, + {'attr': 'b', 'value': 1, 'op': 'eq', 'negate': True}, ] }) self.assertTrue(cs.eval({'a': 1, 'b': 2})) - self.assertFalse(cs.eval({'a': 1, 'b': 3})) + self.assertFalse(cs.eval({'a': 1, 'b': 1})) def test_or_single_depth(self): cs = ConditionSet({ From 2423e0872ff5e1953b4647a8617954870ec39064 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 25 Oct 2021 09:52:08 -0400 Subject: [PATCH 5/6] Documentation & changelog for #6238 --- docs/models/extras/webhook.md | 14 +++++ docs/reference/conditions.md | 89 +++++++++++++++++++++++++++++++ docs/release-notes/version-3.1.md | 16 ++++++ mkdocs.yml | 2 + 4 files changed, 121 insertions(+) create mode 100644 docs/reference/conditions.md diff --git a/docs/models/extras/webhook.md b/docs/models/extras/webhook.md index ee5e9d059..c71657336 100644 --- a/docs/models/extras/webhook.md +++ b/docs/models/extras/webhook.md @@ -17,6 +17,7 @@ A webhook is a mechanism for conveying to some external system a change that too * **Additional headers** - Any additional headers to include with the request (optional). Add one header per line in the format `Name: Value`. Jinja2 templating is supported for this field (see below). * **Body template** - The content of the request being sent (optional). Jinja2 templating is supported for this field (see below). If blank, NetBox will populate the request body with a raw dump of the webhook context. (If the HTTP cotent type is set to `application/json`, this will be formatted as a JSON object.) * **Secret** - A secret string used to prove authenticity of the request (optional). This will append a `X-Hook-Signature` header to the request, consisting of a HMAC (SHA-512) hex digest of the request body using the secret as the key. +* **Conditions** - An optional set of conditions evaluated to determine whether the webhook fires for a given object. * **SSL verification** - Uncheck this option to disable validation of the receiver's SSL certificate. (Disable with caution!) * **CA file path** - The file path to a particular certificate authority (CA) file to use when validating the receiver's SSL certificate (optional). @@ -80,3 +81,16 @@ If no body template is specified, the request body will be populated with a JSON } } ``` + +## Conditional Webhooks + +A webhook may include a set of conditional logic expressed in JSON used to control whether a webhook triggers for a specific object. For example, you may wish to trigger a webhook for devices only when the `status` field of an object is "active": + +```json +{ + "attr": "status", + "value": "active" +} +``` + +For more detail, see the reference documentation for NetBox's [conditional logic](../reference/conditions.md). diff --git a/docs/reference/conditions.md b/docs/reference/conditions.md new file mode 100644 index 000000000..c335bf9a8 --- /dev/null +++ b/docs/reference/conditions.md @@ -0,0 +1,89 @@ +# Conditions + +Conditions are NetBox's mechanism for evaluating whether a set data meets a prescribed set of conditions. It allows the author to convey simple logic by declaring an arbitrary number of attribute-value-operation tuples nested within a hierarchy of logical AND and OR statements. + +## Conditions + +A condition is expressed as a JSON object with the following keys: + +| Key name | Required | Default | Description | +|----------|----------|---------|-------------| +| attr | Yes | - | Name of the key within the data being evaluated | +| value | Yes | - | The reference value to which the given data will be compared | +| op | No | `eq` | The logical operation to be performed | +| negate | No | False | Negate (invert) the result of the condition's evaluation | + +### Available Operations + +* `eq`: Equals +* `gt`: Greater than +* `gte`: Greater than or equal to +* `lt`: Less than +* `lte`: Less than or equal to +* `in`: Is present within a list of values +* `contains`: Contains the specified value + +### Examples + +`name` equals "foobar": + +```json +{ + "attr": "name", + "value": "foobar" +} +``` + +`asn` is greater than 65000: + +```json +{ + "attr": "asn", + "value": 65000, + "op": "gt" +} +``` + +`status` is not "planned" or "staging": + +```json +{ + "attr": "status", + "value": ["planned", "staging"], + "op": "in", + "negate": true +} +``` + +## Condition Sets + +Multiple conditions can be combined into nested sets using AND or OR logic. This is done by declaring a JSON object with a single key (`and` or `or`) containing a list of condition objects and/or child condition sets. + +### Examples + +`status` is "active" and `primary_ip` is defined _or_ the "exempt" tag is applied. + +```json +{ + "or": [ + { + "and": [ + { + "attr": "status", + "value": "active" + }, + { + "attr": "primary_ip", + "value": "", + "negate": true + } + ] + }, + { + "attr": "tags", + "value": "exempt", + "op": "contains" + } + ] +} +``` diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index f586f43bb..b047e1320 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -28,6 +28,20 @@ Both types of connection include SSID and authentication attributes. Additionall * Channel - A predefined channel within a standardized band * Channel frequency & width - Customizable channel attributes (e.g. for licensed bands) +#### Conditional Webhooks ([#6238](https://github.com/netbox-community/netbox/issues/6238)) + +Webhooks now include a `conditions` field, which may be used to specify conditions under which a webhook triggers. For example, you may wish to generate outgoing requests for a device webhook only when its status is "active" or "staged". This can be done by declaring conditional logic in JSON: + +```json +{ + "attr": "status", + "op": "in", + "value": ["active", "staged"] +} +``` + +Multiple conditions may be nested using AND/OR logic as well. For more information, please see the [conditional logic documentation](../reference/conditions.md). + #### Interface Bridging ([#6346](https://github.com/netbox-community/netbox/issues/6346)) A `bridge` field has been added to the interface model for devices and virtual machines. This can be set to reference another interface on the same parent device/VM to indicate a direct layer two bridging adjacency. @@ -85,5 +99,7 @@ Multiple interfaces can be bridged to a single virtual interface to effect a bri * Added `wwn` field * dcim.Location * Added `tenant` field +* extras.Webhook + * Added the `conditions` field * virtualization.VMInterface * Added `bridge` field diff --git a/mkdocs.yml b/mkdocs.yml index 001808f0d..9d9bb964a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -93,6 +93,8 @@ nav: - Authentication: 'rest-api/authentication.md' - GraphQL API: - Overview: 'graphql-api/overview.md' + - Reference: + - Conditions: 'reference/conditions.md' - Development: - Introduction: 'development/index.md' - Getting Started: 'development/getting-started.md' From 0d84338e28c4a34036b168dfcdb075b0bf091449 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 25 Oct 2021 10:14:18 -0400 Subject: [PATCH 6/6] Add regex condition op --- netbox/extras/conditions.py | 11 +++++++++-- netbox/extras/tests/test_conditions.py | 10 ++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/netbox/extras/conditions.py b/netbox/extras/conditions.py index 7f1d804e8..6f1b012eb 100644 --- a/netbox/extras/conditions.py +++ b/netbox/extras/conditions.py @@ -1,4 +1,5 @@ import functools +import re __all__ = ( 'Condition', @@ -32,13 +33,14 @@ class Condition: LTE = 'lte' IN = 'in' CONTAINS = 'contains' + REGEX = 'regex' OPERATORS = ( - EQ, GT, GTE, LT, LTE, IN, CONTAINS + EQ, GT, GTE, LT, LTE, IN, CONTAINS, REGEX ) TYPES = { - str: (EQ, CONTAINS), + str: (EQ, CONTAINS, REGEX), bool: (EQ, CONTAINS), int: (EQ, GT, GTE, LT, LTE, CONTAINS), float: (EQ, GT, GTE, LT, LTE, CONTAINS), @@ -99,6 +101,11 @@ class Condition: def eval_contains(self, value): return self.value in value + # Regular expressions + + def eval_regex(self, value): + return re.match(self.value, value) is not None + class ConditionSet: """ diff --git a/netbox/extras/tests/test_conditions.py b/netbox/extras/tests/test_conditions.py index 47ae0b662..ee6afeaf6 100644 --- a/netbox/extras/tests/test_conditions.py +++ b/netbox/extras/tests/test_conditions.py @@ -95,6 +95,16 @@ class ConditionTestCase(TestCase): self.assertFalse(c.eval({'x': [1, 2, 3]})) self.assertTrue(c.eval({'x': [2, 3, 4]})) + def test_regex(self): + c = Condition('x', '[a-z]+', 'regex') + self.assertTrue(c.eval({'x': 'abc'})) + self.assertFalse(c.eval({'x': '123'})) + + def test_regex_negated(self): + c = Condition('x', '[a-z]+', 'regex', negate=True) + self.assertFalse(c.eval({'x': 'abc'})) + self.assertTrue(c.eval({'x': '123'})) + class ConditionSetTest(TestCase):