diff --git a/docs/release-notes/artifacts/pr0401.yaml b/docs/release-notes/artifacts/pr0401.yaml new file mode 100644 index 00000000..490a4332 --- /dev/null +++ b/docs/release-notes/artifacts/pr0401.yaml @@ -0,0 +1,20 @@ +version_schema: 2 + +changes: + - title: Added rule matching engine and request evaluation on creation + author: tphan025 + type: minor + description: > + Added a rule matching engine that evaluates backend requests against rules + ordered by descending priority. Within the same priority group, deny rules + take precedence over allow rules. Integrated the engine into the bulk create + endpoint so that each new request is evaluated immediately and its status is + set to accepted, rejected, or pending accordingly. Included unit tests for the + matching logic and integration tests for rule evaluation during request creation. + urls: + pr: + - https://github.com/canonical/haproxy-operator/pull/401 + related_doc: + related_issue: + visibility: public + highlight: false diff --git a/haproxy-route-policy/policy/db_models.py b/haproxy-route-policy/policy/db_models.py index 7eb294f6..03b178c3 100644 --- a/haproxy-route-policy/policy/db_models.py +++ b/haproxy-route-policy/policy/db_models.py @@ -93,7 +93,7 @@ class BackendRequest(models.Model): hostname_acls: models.JSONField = models.JSONField( default=list, validators=[validate_hostname_acls], blank=True ) - backend_name: models.TextField = models.TextField() + backend_name: models.TextField = models.TextField(unique=True) paths: models.JSONField = models.JSONField( default=list, validators=[validate_paths], blank=True ) diff --git a/haproxy-route-policy/policy/migrations/0001_initial.py b/haproxy-route-policy/policy/migrations/0001_initial.py index 7d7a16bc..17c86d4b 100644 --- a/haproxy-route-policy/policy/migrations/0001_initial.py +++ b/haproxy-route-policy/policy/migrations/0001_initial.py @@ -32,9 +32,19 @@ class Migration(migrations.Migration): validators=[policy.db_models.validate_hostname_acls], ), ), - ("backend_name", models.TextField()), - ("paths", models.JSONField(blank=True, default=list)), - ("port", models.IntegerField()), + ("backend_name", models.TextField(unique=True)), + ( + "paths", + models.JSONField( + blank=True, + default=list, + validators=[policy.db_models.validate_paths], + ), + ), + ( + "port", + models.IntegerField(validators=[policy.db_models.validate_port]), + ), ( "status", models.TextField( diff --git a/haproxy-route-policy/policy/migrations/0002_rule_alter_backendrequest_paths_and_more.py b/haproxy-route-policy/policy/migrations/0002_rule.py similarity index 71% rename from haproxy-route-policy/policy/migrations/0002_rule_alter_backendrequest_paths_and_more.py rename to haproxy-route-policy/policy/migrations/0002_rule.py index 0a7b61e4..4f3fa0ae 100644 --- a/haproxy-route-policy/policy/migrations/0002_rule_alter_backendrequest_paths_and_more.py +++ b/haproxy-route-policy/policy/migrations/0002_rule.py @@ -1,6 +1,5 @@ -# Generated by Django 6.0.3 on 2026-03-23 21:53 +# Generated by Django 6.0.3 on 2026-03-24 14:42 -import policy.db_models import uuid from django.db import migrations, models @@ -40,16 +39,4 @@ class Migration(migrations.Migration): ("updated_at", models.DateTimeField(auto_now=True)), ], ), - migrations.AlterField( - model_name="backendrequest", - name="paths", - field=models.JSONField( - blank=True, default=list, validators=[policy.db_models.validate_paths] - ), - ), - migrations.AlterField( - model_name="backendrequest", - name="port", - field=models.IntegerField(validators=[policy.db_models.validate_port]), - ), ] diff --git a/haproxy-route-policy/policy/rule_engine.py b/haproxy-route-policy/policy/rule_engine.py new file mode 100644 index 00000000..c59a2753 --- /dev/null +++ b/haproxy-route-policy/policy/rule_engine.py @@ -0,0 +1,122 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Rule matching engine for evaluating backend requests against rules. + +Rules are evaluated following these principles: + P1: Rules are grouped by priority and evaluated starting from the highest + priority group. + P2: Within the same priority group, "deny" rules take precedence over + "allow" rules. + +If no rules match a request, its status remains "pending". +""" + +import logging +from itertools import groupby +from policy.db_models import ( + BackendRequest, + Rule, + RULE_ACTION_ALLOW, + RULE_ACTION_DENY, + RULE_KIND_HOSTNAME_AND_PATH_MATCH, + REQUEST_STATUS_ACCEPTED, + REQUEST_STATUS_REJECTED, + REQUEST_STATUS_PENDING, +) + +logger = logging.getLogger(__name__) + + +def _hostname_and_path_match(rule: Rule, request: BackendRequest) -> bool: + """Check if a hostname_and_path_match rule matches a backend request. + + A rule matches if: + 1. Any of the rule's `hostnames` appear in the request's `hostname_acls` + if `hostnames` is not empty. + 2. Any of the rule's `paths` appear in the request's `paths` + if `paths` is not empty.. + + Args: + rule: The rule to check. + request: The backend request to evaluate. + + Returns: + True if the rule matches the request, False otherwise. + """ + rule_hostnames: list = rule.parameters.get("hostnames", []) + rule_paths: list = rule.parameters.get("paths", []) + + # A rule with no hostnames can never match. + if not rule_hostnames: + return False + + # At least one rule hostname must appear in the request's hostname_acls. + hostname_matched = bool(set(rule_hostnames).intersection(request.hostname_acls)) + if not hostname_matched: + return False + + # Empty rule paths means "match all paths" (wildcard). + if not rule_paths: + return True + + # At least one rule path must appear in the request's paths. + return bool(set(rule_paths).intersection(request.paths)) + + +def evaluate_request(request: BackendRequest) -> str: + """Evaluate a backend request against all rules and return the resulting status. + + Rules are fetched from the database, ordered by descending priority. + They are grouped by priority level and evaluated from highest to lowest. + + Within the same priority group: + - If any "deny" rule matches, the request is rejected. + - If any "allow" rule matches (and no deny matched), the request is accepted. + - If no rules match at this priority level, move to the next group. + + If no rules match at any priority level, the request stays "pending". + + Args: + request: The backend request to evaluate. + + Returns: + The resulting status string: "accepted", "rejected", or "pending". + """ + rules = Rule.objects.all().order_by("-priority") + + for _priority, group in groupby(rules, key=lambda rule: rule.priority): + allow_matched = False + deny_matched = False + + for rule in group: + if not _matches(rule, request): + continue + + if rule.action == RULE_ACTION_DENY: + deny_matched = True + elif rule.action == RULE_ACTION_ALLOW: + allow_matched = True + + # P2: deny rules have priority over allow rules within the same priority level + if deny_matched: + return REQUEST_STATUS_REJECTED + if allow_matched: + return REQUEST_STATUS_ACCEPTED + + return REQUEST_STATUS_PENDING + + +def _matches(rule: Rule, request: BackendRequest) -> bool: + """Dispatch matching logic based on the rule kind. + + Args: + rule: The rule to evaluate. + request: The backend request to evaluate against. + + Returns: + True if the rule matches the request. + """ + if rule.kind == RULE_KIND_HOSTNAME_AND_PATH_MATCH: + return _hostname_and_path_match(rule, request) + return False diff --git a/haproxy-route-policy/policy/tests/test_rule_engine.py b/haproxy-route-policy/policy/tests/test_rule_engine.py new file mode 100644 index 00000000..03666b17 --- /dev/null +++ b/haproxy-route-policy/policy/tests/test_rule_engine.py @@ -0,0 +1,313 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Unit tests for the rule matching engine.""" + +from django.test import TestCase + +from policy import db_models +from policy.rule_engine import evaluate_request, _hostname_and_path_match + + +class TestHostnameAndPathMatch(TestCase): + """Tests for the _hostname_and_path_match matching function.""" + + def _make_request(self, hostname_acls=None, paths=None): + """Create and save a BackendRequest with the given hostnames and paths.""" + return db_models.BackendRequest.objects.create( + relation_id=1, + backend_name="test-backend", + hostname_acls=hostname_acls or [], + paths=paths or [], + port=443, + ) + + def _make_rule(self, hostnames=None, paths=None, action="deny", priority=0): + """Create and save a Rule with hostname_and_path_match kind.""" + rule = db_models.Rule( + kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + parameters={"hostnames": hostnames or [], "paths": paths or []}, + action=action, + priority=priority, + ) + rule.save() + return rule + + def test_exact_hostname_match(self): + """Rule matches when hostnames overlap exactly.""" + rule = self._make_rule(hostnames=["example.com"]) + request = self._make_request(hostname_acls=["example.com"]) + self.assertTrue(_hostname_and_path_match(rule, request)) + + def test_hostname_no_overlap(self): + """Rule does not match when hostnames don't overlap.""" + rule = self._make_rule(hostnames=["example.com"]) + request = self._make_request(hostname_acls=["other.com"]) + self.assertFalse(_hostname_and_path_match(rule, request)) + + def test_hostname_partial_overlap(self): + """Rule matches when at least one hostname overlaps.""" + rule = self._make_rule(hostnames=["example.com", "other.com"]) + request = self._make_request(hostname_acls=["example.com", "third.com"]) + self.assertTrue(_hostname_and_path_match(rule, request)) + + def test_empty_rule_hostnames_no_match(self): + """Rule with empty hostnames never matches.""" + rule = self._make_rule(hostnames=[]) + request = self._make_request(hostname_acls=["example.com"]) + self.assertFalse(_hostname_and_path_match(rule, request)) + + def test_empty_request_hostnames_no_match(self): + """Request with empty hostname_acls doesn't match a hostname rule.""" + rule = self._make_rule(hostnames=["example.com"]) + request = self._make_request(hostname_acls=[]) + self.assertFalse(_hostname_and_path_match(rule, request)) + + def test_empty_rule_paths_matches_all_paths(self): + """Rule with empty paths list matches any request paths (wildcard).""" + rule = self._make_rule(hostnames=["example.com"], paths=[]) + request = self._make_request( + hostname_acls=["example.com"], paths=["/api", "/health"] + ) + self.assertTrue(_hostname_and_path_match(rule, request)) + + def test_empty_rule_paths_matches_empty_request_paths(self): + """Rule with empty paths matches requests with no paths.""" + rule = self._make_rule(hostnames=["example.com"], paths=[]) + request = self._make_request(hostname_acls=["example.com"], paths=[]) + self.assertTrue(_hostname_and_path_match(rule, request)) + + def test_path_overlap(self): + """Rule matches when paths overlap.""" + rule = self._make_rule(hostnames=["example.com"], paths=["/api"]) + request = self._make_request( + hostname_acls=["example.com"], paths=["/api", "/health"] + ) + self.assertTrue(_hostname_and_path_match(rule, request)) + + def test_path_no_overlap(self): + """Rule does not match when paths don't overlap.""" + rule = self._make_rule(hostnames=["example.com"], paths=["/admin"]) + request = self._make_request( + hostname_acls=["example.com"], paths=["/api", "/health"] + ) + self.assertFalse(_hostname_and_path_match(rule, request)) + + def test_rule_paths_set_but_request_paths_empty(self): + """Rule with specific paths does not match request with no paths.""" + rule = self._make_rule(hostnames=["example.com"], paths=["/api"]) + request = self._make_request(hostname_acls=["example.com"], paths=[]) + self.assertFalse(_hostname_and_path_match(rule, request)) + + def test_hostname_match_but_path_mismatch(self): + """Rule doesn't match when hostnames match but paths don't.""" + rule = self._make_rule(hostnames=["example.com"], paths=["/admin"]) + request = self._make_request(hostname_acls=["example.com"], paths=["/api"]) + self.assertFalse(_hostname_and_path_match(rule, request)) + + def test_multiple_hostnames_and_paths(self): + """Rule matches with multiple hostnames and paths that overlap.""" + rule = self._make_rule( + hostnames=["example.com", "other.com"], + paths=["/api", "/v2"], + ) + request = self._make_request( + hostname_acls=["other.com"], paths=["/v2", "/health"] + ) + self.assertTrue(_hostname_and_path_match(rule, request)) + + +class TestEvaluateRequest(TestCase): + """Tests for the evaluate_request function.""" + + def _make_request(self, hostname_acls=None, paths=None): + """Create and save a BackendRequest.""" + return db_models.BackendRequest.objects.create( + relation_id=1, + backend_name="test-backend", + hostname_acls=hostname_acls or [], + paths=paths or [], + port=443, + ) + + def _make_rule(self, hostnames=None, paths=None, action="deny", priority=0): + """Create and save a hostname_and_path_match Rule.""" + rule = db_models.Rule( + kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + parameters={"hostnames": hostnames or [], "paths": paths or []}, + action=action, + priority=priority, + ) + rule.save() + return rule + + def test_no_rules_returns_pending(self): + """Request stays pending when no rules exist.""" + request = self._make_request(hostname_acls=["example.com"]) + self.assertEqual(evaluate_request(request), db_models.REQUEST_STATUS_PENDING) + + def test_no_matching_rules_returns_pending(self): + """Request stays pending when no rules match.""" + self._make_rule(hostnames=["other.com"], action=db_models.RULE_ACTION_DENY) + request = self._make_request(hostname_acls=["example.com"]) + self.assertEqual(evaluate_request(request), db_models.REQUEST_STATUS_PENDING) + + def test_single_allow_rule_accepts(self): + """Request is accepted when a single allow rule matches.""" + self._make_rule(hostnames=["example.com"], action=db_models.RULE_ACTION_ALLOW) + request = self._make_request(hostname_acls=["example.com"]) + self.assertEqual(evaluate_request(request), db_models.REQUEST_STATUS_ACCEPTED) + + def test_single_deny_rule_rejects(self): + """Request is rejected when a single deny rule matches.""" + self._make_rule(hostnames=["example.com"], action=db_models.RULE_ACTION_DENY) + request = self._make_request(hostname_acls=["example.com"]) + self.assertEqual(evaluate_request(request), db_models.REQUEST_STATUS_REJECTED) + + def test_deny_wins_over_allow_at_same_priority(self): + """Deny rule takes precedence over allow rule at the same priority.""" + self._make_rule( + hostnames=["example.com"], + action=db_models.RULE_ACTION_ALLOW, + priority=0, + ) + self._make_rule( + hostnames=["example.com"], + action=db_models.RULE_ACTION_DENY, + priority=0, + ) + request = self._make_request(hostname_acls=["example.com"]) + self.assertEqual(evaluate_request(request), db_models.REQUEST_STATUS_REJECTED) + + def test_higher_priority_evaluated_first(self): + """Higher priority rules are evaluated before lower priority ones.""" + # Priority 1: allow example.com/client + self._make_rule( + hostnames=["example.com"], + paths=["/client"], + action=db_models.RULE_ACTION_ALLOW, + priority=1, + ) + # Priority 0: deny example.com (all paths) + self._make_rule( + hostnames=["example.com"], + action=db_models.RULE_ACTION_DENY, + priority=0, + ) + request = self._make_request(hostname_acls=["example.com"], paths=["/client"]) + self.assertEqual(evaluate_request(request), db_models.REQUEST_STATUS_ACCEPTED) + + def test_spec_example_client_allowed(self): + """Spec example: request for example.com/client is allowed. + + Rules: + Rule 1: deny example.com (all paths), priority=0 + Rule 2: allow example.com /api, priority=0 + Rule 3: allow example.com /client, priority=1 + """ + # Rule 1 + self._make_rule( + hostnames=["example.com"], + paths=[], + action=db_models.RULE_ACTION_DENY, + priority=0, + ) + # Rule 2 + self._make_rule( + hostnames=["example.com"], + paths=["/api"], + action=db_models.RULE_ACTION_ALLOW, + priority=0, + ) + # Rule 3 + self._make_rule( + hostnames=["example.com"], + paths=["/client"], + action=db_models.RULE_ACTION_ALLOW, + priority=1, + ) + request = self._make_request(hostname_acls=["example.com"], paths=["/client"]) + self.assertEqual(evaluate_request(request), db_models.REQUEST_STATUS_ACCEPTED) + + def test_spec_example_api_denied(self): + """Spec example: request for example.com/api is denied. + + Rules: + Rule 1: deny example.com (all paths), priority=0 + Rule 2: allow example.com /api, priority=0 + Rule 3: allow example.com /client, priority=1 + """ + # Rule 1 + self._make_rule( + hostnames=["example.com"], + paths=[], + action=db_models.RULE_ACTION_DENY, + priority=0, + ) + # Rule 2 + self._make_rule( + hostnames=["example.com"], + paths=["/api"], + action=db_models.RULE_ACTION_ALLOW, + priority=0, + ) + # Rule 3 + self._make_rule( + hostnames=["example.com"], + paths=["/client"], + action=db_models.RULE_ACTION_ALLOW, + priority=1, + ) + request = self._make_request(hostname_acls=["example.com"], paths=["/api"]) + self.assertEqual(evaluate_request(request), db_models.REQUEST_STATUS_REJECTED) + + def test_lower_priority_not_reached_if_higher_matches(self): + """If a higher priority group matches, lower priority groups are skipped.""" + # Priority 5: allow example.com + self._make_rule( + hostnames=["example.com"], + action=db_models.RULE_ACTION_ALLOW, + priority=5, + ) + # Priority 0: deny example.com + self._make_rule( + hostnames=["example.com"], + action=db_models.RULE_ACTION_DENY, + priority=0, + ) + request = self._make_request(hostname_acls=["example.com"]) + self.assertEqual(evaluate_request(request), db_models.REQUEST_STATUS_ACCEPTED) + + def test_only_matching_rules_affect_outcome(self): + """Non-matching rules at the same priority don't affect the result.""" + # Deny other.com at priority 0 + self._make_rule( + hostnames=["other.com"], + action=db_models.RULE_ACTION_DENY, + priority=0, + ) + # Allow example.com at priority 0 + self._make_rule( + hostnames=["example.com"], + action=db_models.RULE_ACTION_ALLOW, + priority=0, + ) + request = self._make_request(hostname_acls=["example.com"]) + self.assertEqual(evaluate_request(request), db_models.REQUEST_STATUS_ACCEPTED) + + def test_multiple_priority_groups_fallthrough(self): + """If highest priority group has no match, fall through to next.""" + # Priority 10: deny other.com (doesn't match) + self._make_rule( + hostnames=["other.com"], + action=db_models.RULE_ACTION_DENY, + priority=10, + ) + # Priority 0: allow example.com + self._make_rule( + hostnames=["example.com"], + action=db_models.RULE_ACTION_ALLOW, + priority=0, + ) + request = self._make_request(hostname_acls=["example.com"]) + self.assertEqual(evaluate_request(request), db_models.REQUEST_STATUS_ACCEPTED) diff --git a/haproxy-route-policy/policy/tests/test_views.py b/haproxy-route-policy/policy/tests/test_views.py index 7db88c79..f71a499c 100644 --- a/haproxy-route-policy/policy/tests/test_views.py +++ b/haproxy-route-policy/policy/tests/test_views.py @@ -88,6 +88,96 @@ def test_bulk_create(self): self.assertEqual(data[1]["port"], 443) self.assertEqual(db_models.BackendRequest.objects.count(), 2) + def test_evaluate_requests(self): + """POST evaluates rules and sets status accordingly for each request.""" + cases = [ + ( + "denied by matching deny rule", + {"hostnames": ["example.com"], "paths": []}, + db_models.RULE_ACTION_DENY, + [ + { + "relation_id": 1, + "hostname_acls": ["example.com"], + "backend_name": "backend-1", + "paths": ["/api"], + "port": 443, + }, + ], + [db_models.REQUEST_STATUS_REJECTED], + ), + ( + "accepted by matching allow rule", + {"hostnames": ["example.com"], "paths": []}, + db_models.RULE_ACTION_ALLOW, + [ + { + "relation_id": 1, + "hostname_acls": ["example.com"], + "backend_name": "backend-1", + "port": 443, + }, + ], + [db_models.REQUEST_STATUS_ACCEPTED], + ), + ( + "pending when no rules match", + {"hostnames": ["other.com"], "paths": []}, + db_models.RULE_ACTION_DENY, + [ + { + "relation_id": 1, + "hostname_acls": ["example.com"], + "backend_name": "backend-1", + "port": 443, + }, + ], + [db_models.REQUEST_STATUS_PENDING], + ), + ( + "mixed statuses per request", + {"hostnames": ["example.com"], "paths": []}, + db_models.RULE_ACTION_DENY, + [ + { + "relation_id": 1, + "hostname_acls": ["example.com"], + "backend_name": "backend-1", + "port": 443, + }, + { + "relation_id": 2, + "hostname_acls": ["other.com"], + "backend_name": "backend-2", + "port": 443, + }, + ], + [ + db_models.REQUEST_STATUS_REJECTED, + db_models.REQUEST_STATUS_PENDING, + ], + ), + ] + for label, rule_params, rule_action, payload, expected_statuses in cases: + with self.subTest(label=label): + # Clean slate for each sub-test + db_models.Rule.objects.all().delete() + db_models.BackendRequest.objects.all().delete() + + db_models.Rule( + kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + parameters=rule_params, + action=rule_action, + ).save() + + response = self.client.post( + "/api/v1/requests", data=payload, format="json" + ) + self.assertEqual(response.status_code, 201) + data = response.json() + actual_statuses = [r["status"] for r in data] + self.assertEqual(actual_statuses, expected_statuses) + def test_bulk_create_rejects_non_list(self): """POST returns 400 when the body is not a list.""" response = self.client.post( diff --git a/haproxy-route-policy/policy/views.py b/haproxy-route-policy/policy/views.py index 9de7b8df..b4483518 100644 --- a/haproxy-route-policy/policy/views.py +++ b/haproxy-route-policy/policy/views.py @@ -5,7 +5,6 @@ from policy.db_models import BackendRequest, Rule from typing import Type -from venv import logger from rest_framework.views import APIView from rest_framework.response import Response from rest_framework.status import ( @@ -19,11 +18,20 @@ from django.db import transaction from policy import serializers from .db_models import REQUEST_STATUSES +from policy.rule_engine import evaluate_request +from .serializers import BackendRequestSerializer, RuleSerializer class ListCreateRequestsView(APIView): """View for listing and bulk-creating backend requests.""" + def get_request_by_backend_name(self, backend_name: str) -> BackendRequest | None: + """Get a backend request by its backend name.""" + try: + return BackendRequest.objects.get(backend_name=backend_name) + except BackendRequest.DoesNotExist: + return None + def get(self, request): """List all requests, optionally filtered by status.""" status = request.GET.get("status") @@ -39,7 +47,9 @@ def get(self, request): def post(self, request): """Bulk create backend requests. - All new requests are set to 'pending' (evaluation logic is deferred). + Each new request is evaluated against existing rules immediately. + If a matching rule is found, the request status is set accordingly. + If no rules match, the request stays as 'pending'. """ if not isinstance(request.data, list): return Response( @@ -51,17 +61,25 @@ def post(self, request): try: with transaction.atomic(): for backend_request in request.data: - serializer = serializers.BackendRequestSerializer( - data=backend_request + # Get the request with the same backend_name if it exists and update it, otherwise create a new one + req = self.get_request_by_backend_name( + backend_request.get("backend_name") ) + serializer = BackendRequestSerializer(req, data=backend_request) if serializer.is_valid(raise_exception=True): - serializer.save() + # Evaluate rules and update status + serializer.save( + status=evaluate_request( + BackendRequest(**serializer.validated_data) + ) + ) created.append(serializer.data) except ValidationError as e: return Response({"error": str(e)}, status=HTTP_400_BAD_REQUEST) - except IntegrityError: + except IntegrityError as e: return Response( - {"error": "Invalid request data."}, status=HTTP_400_BAD_REQUEST + {"error": f"Invalid request data: {str(e)}"}, + status=HTTP_400_BAD_REQUEST, ) return Response(created, status=HTTP_201_CREATED) @@ -87,12 +105,12 @@ class ListCreateRulesView(APIView): def get(self, request): """List all rules.""" queryset = Rule.objects.all().order_by("-priority", "created_at") - serializer = serializers.RuleSerializer(queryset, many=True) + serializer = RuleSerializer(queryset, many=True) return Response(serializer.data) def post(self, request): """Create a new rule.""" - serializer = serializers.RuleSerializer(data=request.data) + serializer = RuleSerializer(data=request.data) if serializer.is_valid(raise_exception=True): serializer.save() return Response(serializer.data, status=HTTP_201_CREATED) @@ -125,7 +143,6 @@ def delete(self, request, pk): def get_object(object_class: Type[Rule] | Type[BackendRequest], pk: str): try: - logger.info(f"Fetching object with ID: {pk}") return object_class.objects.get(pk=pk) except object_class.DoesNotExist: raise Http404