From 44bf7c3b73fc2d227a820a06a5e4d3269f178e3e Mon Sep 17 00:00:00 2001 From: Sam Oehlert Date: Tue, 18 Nov 2025 23:58:54 -0600 Subject: [PATCH 01/23] feat(API): add is_blocked API views and serializer --- scram/route_manager/api/serializers.py | 7 +++++++ scram/route_manager/api/views.py | 11 ++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/scram/route_manager/api/serializers.py b/scram/route_manager/api/serializers.py index 5c790f54..5f9e83b3 100644 --- a/scram/route_manager/api/serializers.py +++ b/scram/route_manager/api/serializers.py @@ -51,6 +51,13 @@ class Meta: model = Client fields = ["hostname", "uuid"] +class IsBlockedSerializer(serializers.ModelSerializer): + """Map the serializer to the model via Meta.""" + + class Meta: + model = Entry + fields = ["is_active"] + class EntrySerializer(serializers.HyperlinkedModelSerializer): """Due to the use of ForeignKeys, this follows some relationships to make sense via the API.""" diff --git a/scram/route_manager/api/views.py b/scram/route_manager/api/views.py index d1da5f8d..2c67c704 100644 --- a/scram/route_manager/api/views.py +++ b/scram/route_manager/api/views.py @@ -16,7 +16,7 @@ from ..models import ActionType, Client, Entry, IgnoreEntry, Route, WebSocketSequenceElement from .exceptions import ActiontypeNotAllowed, IgnoredRoute, NoActiveEntryFound, PrefixTooLarge -from .serializers import ActionTypeSerializer, ClientSerializer, EntrySerializer, IgnoreEntrySerializer +from .serializers import ActionTypeSerializer, ClientSerializer, EntrySerializer, IgnoreEntrySerializer, IsBlockedSerializer channel_layer = get_channel_layer() logger = logging.getLogger(__name__) @@ -62,6 +62,15 @@ class ClientViewSet(viewsets.ModelViewSet): lookup_field = "hostname" http_method_names = ["post"] +class IsBlockedViewSet(viewsets.ReadOnlyModelViewSet): # Use ReadOnly if you only need GET + serializer_class = IsBlockedSerializer + + def get_queryset(self): + queryset = Entry.objects.filter(is_active=True) + ip_address = self.request.query_params.get("ip") + if ip_address: + queryset = queryset.filter(route__route=ip_address) + return queryset @extend_schema( description="API endpoint for entries", From 05d5f54a6e146521d3d2847e374fc53a2372f832 Mon Sep 17 00:00:00 2001 From: Sam Oehlert Date: Tue, 18 Nov 2025 23:59:17 -0600 Subject: [PATCH 02/23] feat(url): add a path for is_blocked to be in WUI --- scram/route_manager/urls.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scram/route_manager/urls.py b/scram/route_manager/urls.py index 46d1aa5b..f884f09e 100644 --- a/scram/route_manager/urls.py +++ b/scram/route_manager/urls.py @@ -14,4 +14,5 @@ path(route="/", view=views.EntryDetailView.as_view(), name="detail"), path("entries/", views.EntryListView.as_view(), name="entry-list"), path("add/", views.add_entry, name="add"), + path("is_blocked//", views.is_blocked, name="is_blocked"), ] From cdf2d328be1139d46af551fe461cb1a8742abbac Mon Sep 17 00:00:00 2001 From: Sam Oehlert Date: Tue, 18 Nov 2025 23:59:53 -0600 Subject: [PATCH 03/23] feat(is_blocked): create an is_blocked view in our WUI views --- scram/route_manager/views.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/scram/route_manager/views.py b/scram/route_manager/views.py index a4f6a544..dac21761 100644 --- a/scram/route_manager/views.py +++ b/scram/route_manager/views.py @@ -23,7 +23,7 @@ from scram.route_manager.models import WebSocketSequenceElement -from ..route_manager.api.views import EntryViewSet +from ..route_manager.api.views import EntryViewSet, IsBlockedViewSet from ..shared.shared_code import make_random_password from ..users.models import User from .models import ActionType, Entry @@ -146,6 +146,18 @@ def add_entry(request): messages.add_message(request, messages.WARNING, f"Something went wrong: {res.status_code}") return redirect("route_manager:home") +is_blocked_api = IsBlockedViewSet.as_view({"get"}) + +def is_blocked(request): + """Check if a user is blocked by an administrator.""" + with transaction.atomic(): + res = is_blocked_api(request) + + if res.status_code == 200 and res: + return True + else: + return False + def process_updates(request): """For entries with an expiration, set them to inactive if expired. @@ -244,3 +256,4 @@ def get_context_data(self, **kwargs): context["entries"] = entries_by_type return context + From 457cede10cff30ded2c2fe1df363bb3832923ace Mon Sep 17 00:00:00 2001 From: Sam Oehlert Date: Wed, 19 Nov 2025 15:43:21 -0600 Subject: [PATCH 04/23] feat(API): actually add the endpoint --- config/api_router.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/api_router.py b/config/api_router.py index a57a12ba..37634032 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -12,7 +12,7 @@ router.register("register_client", ClientViewSet) router.register("entries", EntryViewSet) router.register("ignore_entries", IgnoreEntryViewSet) - +router.register("is_blocked", IsBlockedViewSet, "is_blocked") app_name = "api" urlpatterns = router.urls From 0f8184d16339bed613e69fd3a2f5af512661976a Mon Sep 17 00:00:00 2001 From: Sam Oehlert Date: Wed, 19 Nov 2025 17:49:36 -0600 Subject: [PATCH 05/23] fix(import): IsBlockedViewSet won't work if you don't import it --- config/api_router.py | 2 +- scram/route_manager/api/views.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config/api_router.py b/config/api_router.py index 37634032..488d7eb3 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -2,7 +2,7 @@ from rest_framework.routers import DefaultRouter -from scram.route_manager.api.views import ActionTypeViewSet, ClientViewSet, EntryViewSet, IgnoreEntryViewSet +from scram.route_manager.api.views import ActionTypeViewSet, ClientViewSet, EntryViewSet, IgnoreEntryViewSet, IsBlockedViewSet from scram.users.api.views import UserViewSet router = DefaultRouter() diff --git a/scram/route_manager/api/views.py b/scram/route_manager/api/views.py index 2c67c704..4e75afd3 100644 --- a/scram/route_manager/api/views.py +++ b/scram/route_manager/api/views.py @@ -62,7 +62,7 @@ class ClientViewSet(viewsets.ModelViewSet): lookup_field = "hostname" http_method_names = ["post"] -class IsBlockedViewSet(viewsets.ReadOnlyModelViewSet): # Use ReadOnly if you only need GET +class IsBlockedViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = IsBlockedSerializer def get_queryset(self): From dfd2247990f48afb6f97e15a2a02c99ec656030e Mon Sep 17 00:00:00 2001 From: Sam Oehlert Date: Thu, 20 Nov 2025 08:57:41 -0600 Subject: [PATCH 06/23] feat(permissions): make this available to anyone without logging in this is meant to be a public endpoint and matches the permissions on the home page to be read only unauthenticated --- scram/route_manager/api/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scram/route_manager/api/views.py b/scram/route_manager/api/views.py index 4e75afd3..ca31ab4d 100644 --- a/scram/route_manager/api/views.py +++ b/scram/route_manager/api/views.py @@ -64,6 +64,7 @@ class ClientViewSet(viewsets.ModelViewSet): class IsBlockedViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = IsBlockedSerializer + permission_classes = (AllowAny,) def get_queryset(self): queryset = Entry.objects.filter(is_active=True) From 7d7c860e7ecbd9867742ff0681e16c84d28d82ed Mon Sep 17 00:00:00 2001 From: Sam Oehlert Date: Thu, 20 Nov 2025 14:41:24 -0600 Subject: [PATCH 07/23] test(is_blocked): make sure to test is_blocked endpoint with both happy and bad path --- scram/route_manager/tests/test_api.py | 68 ++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/scram/route_manager/tests/test_api.py b/scram/route_manager/tests/test_api.py index 1a81e907..d77e5acb 100644 --- a/scram/route_manager/tests/test_api.py +++ b/scram/route_manager/tests/test_api.py @@ -5,7 +5,7 @@ from rest_framework import status from rest_framework.test import APITestCase -from scram.route_manager.models import Client +from scram.route_manager.models import Client, Entry, Route, ActionType class TestAddRemoveIP(APITestCase): @@ -125,3 +125,69 @@ def test_unauthenticated_users_have_no_list_access(self): """Ensure an unauthenticated client can't list Entries.""" response = self.client.get(self.entry_url, format="json") self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + +class TestIsBlocked(APITestCase): + """Test the is_blocked endpoint.""" + + def setUp(self): + """Set up test data.""" + self.url = reverse("api:v1:is_blocked-list") + self.authorized_client = Client.objects.create( + hostname="authorized_client.es.net", + uuid="0e7e1cbd-7d73-4968-bc4b-ce3265dc2fd3", + is_authorized=True, + ) + self.authorized_client.authorized_actiontypes.set([1]) + self.actiontype, _ = ActionType.objects.get_or_create(pk=1, defaults={'name': 'block'}) + + # Create some blocked entries + from scram.route_manager.models import Entry, Route + + # Active blocked IPv4 + route_v4 = Route.objects.create(route="192.0.2.100") + Entry.objects.create(route=route_v4, is_active=True, comment="test block", who="test", actiontype=self.actiontype) + + # Active blocked IPv6 + route_v6 = Route.objects.create(route="2001:db8::1") + Entry.objects.create(route=route_v6, is_active=True, comment="test block v6", who="test", actiontype=self.actiontype) + + # Deactivated IPv4 entry + route_inactive = Route.objects.create(route="192.0.2.200") + Entry.objects.create(route=route_inactive, is_active=False, comment="inactive", who="test", actiontype=self.actiontype) + + # Deactived IPv6 entry + route_inactive = Route.objects.create(route="2001:db8::5") + Entry.objects.create(route=route_inactive, is_active=False, comment="inactive", who="test", actiontype=self.actiontype) + + def test_blocked_ipv4_returns_true(self): + """Check that a blocked IPv4 returns is_active=true.""" + response = self.client.get(self.url, {"ip": "192.0.2.100"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, {"is_active": True}) + + def test_blocked_ipv6_returns_true(self): + """Check that a blocked IPv6 returns is_active=true.""" + response = self.client.get(self.url, {"ip": "2001:db8::1"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, {"is_active": True}) + + def test_inactive_entry_ipv4_returns_false(self): + """Check that an inactive entry returns is_active=false.""" + response = self.client.get(self.url, {"ip": "192.0.2.200"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, {"is_active": False}) + + def test_inactive_entry_ipv6_returns_false(self): + """Check that an inactive entry returns is_active=false.""" + response = self.client.get(self.url, {"ip": "2001:db8::5"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, {"is_active": False}) + + def test_unauthenticated_access_allowed(self): + """Ensure unauthenticated clients can check if IPs are blocked.""" + # Logout any authenticated user + self.client.logout() + response = self.client.get(self.url, {"ip": "192.0.2.100"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, {"is_active": True}) From 452ee00855a5aa14ea86af4185e45910440907c0 Mon Sep 17 00:00:00 2001 From: Sam Oehlert Date: Thu, 20 Nov 2025 14:42:16 -0600 Subject: [PATCH 08/23] refactor(WUI): remove is_blocked view from WUI views WUI already has a search function which points to an entry detail that handles this --- scram/route_manager/views.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/scram/route_manager/views.py b/scram/route_manager/views.py index dac21761..87ae65e7 100644 --- a/scram/route_manager/views.py +++ b/scram/route_manager/views.py @@ -146,18 +146,6 @@ def add_entry(request): messages.add_message(request, messages.WARNING, f"Something went wrong: {res.status_code}") return redirect("route_manager:home") -is_blocked_api = IsBlockedViewSet.as_view({"get"}) - -def is_blocked(request): - """Check if a user is blocked by an administrator.""" - with transaction.atomic(): - res = is_blocked_api(request) - - if res.status_code == 200 and res: - return True - else: - return False - def process_updates(request): """For entries with an expiration, set them to inactive if expired. From b6690f2842e1fe3d4c5d032c24a7f8b4f290f7f3 Mon Sep 17 00:00:00 2001 From: Sam Oehlert Date: Thu, 20 Nov 2025 14:42:39 -0600 Subject: [PATCH 09/23] refactor(WUI): remove URL path from WUI --- scram/route_manager/urls.py | 1 - 1 file changed, 1 deletion(-) diff --git a/scram/route_manager/urls.py b/scram/route_manager/urls.py index f884f09e..46d1aa5b 100644 --- a/scram/route_manager/urls.py +++ b/scram/route_manager/urls.py @@ -14,5 +14,4 @@ path(route="/", view=views.EntryDetailView.as_view(), name="detail"), path("entries/", views.EntryListView.as_view(), name="entry-list"), path("add/", views.add_entry, name="add"), - path("is_blocked//", views.is_blocked, name="is_blocked"), ] From 377aba82c6148d78e950b92a693d2733e18cb95f Mon Sep 17 00:00:00 2001 From: Sam Oehlert Date: Thu, 20 Nov 2025 14:43:14 -0600 Subject: [PATCH 10/23] refactor(API-View): make sure we only accept GET requests and also format the output to be simpler --- scram/route_manager/api/views.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scram/route_manager/api/views.py b/scram/route_manager/api/views.py index ca31ab4d..aac2fbf9 100644 --- a/scram/route_manager/api/views.py +++ b/scram/route_manager/api/views.py @@ -62,9 +62,11 @@ class ClientViewSet(viewsets.ModelViewSet): lookup_field = "hostname" http_method_names = ["post"] + class IsBlockedViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = IsBlockedSerializer permission_classes = (AllowAny,) + http_method_names = ["get"] def get_queryset(self): queryset = Entry.objects.filter(is_active=True) @@ -73,6 +75,12 @@ def get_queryset(self): queryset = queryset.filter(route__route=ip_address) return queryset + def list(self, request): + entry = self.get_queryset().first() + is_active = entry is not None + + return Response({"is_active": is_active}) + @extend_schema( description="API endpoint for entries", responses={200: EntrySerializer}, From 5cc37a2e9c539121a754b892a0b5437410df9000 Mon Sep 17 00:00:00 2001 From: Sam Oehlert Date: Thu, 20 Nov 2025 14:49:28 -0600 Subject: [PATCH 11/23] refactor(ruff): fix ruff errors --- config/api_router.py | 8 ++++++- scram/route_manager/api/serializers.py | 3 +++ scram/route_manager/api/views.py | 13 +++++++++++- .../tests/acceptance/steps/common.py | 7 +------ scram/route_manager/tests/test_api.py | 21 ++++++++++++------- scram/route_manager/views.py | 3 +-- 6 files changed, 38 insertions(+), 17 deletions(-) diff --git a/config/api_router.py b/config/api_router.py index 488d7eb3..9f163f29 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -2,7 +2,13 @@ from rest_framework.routers import DefaultRouter -from scram.route_manager.api.views import ActionTypeViewSet, ClientViewSet, EntryViewSet, IgnoreEntryViewSet, IsBlockedViewSet +from scram.route_manager.api.views import ( + ActionTypeViewSet, + ClientViewSet, + EntryViewSet, + IgnoreEntryViewSet, + IsBlockedViewSet, +) from scram.users.api.views import UserViewSet router = DefaultRouter() diff --git a/scram/route_manager/api/serializers.py b/scram/route_manager/api/serializers.py index 5f9e83b3..9b051c97 100644 --- a/scram/route_manager/api/serializers.py +++ b/scram/route_manager/api/serializers.py @@ -51,10 +51,13 @@ class Meta: model = Client fields = ["hostname", "uuid"] + class IsBlockedSerializer(serializers.ModelSerializer): """Map the serializer to the model via Meta.""" class Meta: + """Maps to the Entry model, but limits to the is_active field.""" + model = Entry fields = ["is_active"] diff --git a/scram/route_manager/api/views.py b/scram/route_manager/api/views.py index aac2fbf9..586a982f 100644 --- a/scram/route_manager/api/views.py +++ b/scram/route_manager/api/views.py @@ -16,7 +16,13 @@ from ..models import ActionType, Client, Entry, IgnoreEntry, Route, WebSocketSequenceElement from .exceptions import ActiontypeNotAllowed, IgnoredRoute, NoActiveEntryFound, PrefixTooLarge -from .serializers import ActionTypeSerializer, ClientSerializer, EntrySerializer, IgnoreEntrySerializer, IsBlockedSerializer +from .serializers import ( + ActionTypeSerializer, + ClientSerializer, + EntrySerializer, + IgnoreEntrySerializer, + IsBlockedSerializer, +) channel_layer = get_channel_layer() logger = logging.getLogger(__name__) @@ -64,11 +70,14 @@ class ClientViewSet(viewsets.ModelViewSet): class IsBlockedViewSet(viewsets.ReadOnlyModelViewSet): + """Look up a route to see if SCRAM considers it active or deactivated.""" + serializer_class = IsBlockedSerializer permission_classes = (AllowAny,) http_method_names = ["get"] def get_queryset(self): + """Focus queryset on active routes.""" queryset = Entry.objects.filter(is_active=True) ip_address = self.request.query_params.get("ip") if ip_address: @@ -76,11 +85,13 @@ def get_queryset(self): return queryset def list(self, request): + """OVerride the list function to just return a boolean instead of other metadata.""" entry = self.get_queryset().first() is_active = entry is not None return Response({"is_active": is_active}) + @extend_schema( description="API endpoint for entries", responses={200: EntrySerializer}, diff --git a/scram/route_manager/tests/acceptance/steps/common.py b/scram/route_manager/tests/acceptance/steps/common.py index cec0692d..2d86fefc 100644 --- a/scram/route_manager/tests/acceptance/steps/common.py +++ b/scram/route_manager/tests/acceptance/steps/common.py @@ -9,12 +9,7 @@ from django import conf from django.urls import reverse -from scram.route_manager.models import ( - ActionType, - Client, - WebSocketMessage, - WebSocketSequenceElement, -) +from scram.route_manager.models import ActionType, Client, WebSocketMessage, WebSocketSequenceElement @given("a {name} actiontype is defined") diff --git a/scram/route_manager/tests/test_api.py b/scram/route_manager/tests/test_api.py index d77e5acb..198e1ab7 100644 --- a/scram/route_manager/tests/test_api.py +++ b/scram/route_manager/tests/test_api.py @@ -5,7 +5,7 @@ from rest_framework import status from rest_framework.test import APITestCase -from scram.route_manager.models import Client, Entry, Route, ActionType +from scram.route_manager.models import ActionType, Client, Entry, Route class TestAddRemoveIP(APITestCase): @@ -139,26 +139,33 @@ def setUp(self): is_authorized=True, ) self.authorized_client.authorized_actiontypes.set([1]) - self.actiontype, _ = ActionType.objects.get_or_create(pk=1, defaults={'name': 'block'}) + self.actiontype, _ = ActionType.objects.get_or_create(pk=1, defaults={"name": "block"}) # Create some blocked entries - from scram.route_manager.models import Entry, Route # Active blocked IPv4 route_v4 = Route.objects.create(route="192.0.2.100") - Entry.objects.create(route=route_v4, is_active=True, comment="test block", who="test", actiontype=self.actiontype) + Entry.objects.create( + route=route_v4, is_active=True, comment="test block", who="test", actiontype=self.actiontype + ) # Active blocked IPv6 route_v6 = Route.objects.create(route="2001:db8::1") - Entry.objects.create(route=route_v6, is_active=True, comment="test block v6", who="test", actiontype=self.actiontype) + Entry.objects.create( + route=route_v6, is_active=True, comment="test block v6", who="test", actiontype=self.actiontype + ) # Deactivated IPv4 entry route_inactive = Route.objects.create(route="192.0.2.200") - Entry.objects.create(route=route_inactive, is_active=False, comment="inactive", who="test", actiontype=self.actiontype) + Entry.objects.create( + route=route_inactive, is_active=False, comment="inactive", who="test", actiontype=self.actiontype + ) # Deactived IPv6 entry route_inactive = Route.objects.create(route="2001:db8::5") - Entry.objects.create(route=route_inactive, is_active=False, comment="inactive", who="test", actiontype=self.actiontype) + Entry.objects.create( + route=route_inactive, is_active=False, comment="inactive", who="test", actiontype=self.actiontype + ) def test_blocked_ipv4_returns_true(self): """Check that a blocked IPv4 returns is_active=true.""" diff --git a/scram/route_manager/views.py b/scram/route_manager/views.py index 87ae65e7..a4f6a544 100644 --- a/scram/route_manager/views.py +++ b/scram/route_manager/views.py @@ -23,7 +23,7 @@ from scram.route_manager.models import WebSocketSequenceElement -from ..route_manager.api.views import EntryViewSet, IsBlockedViewSet +from ..route_manager.api.views import EntryViewSet from ..shared.shared_code import make_random_password from ..users.models import User from .models import ActionType, Entry @@ -244,4 +244,3 @@ def get_context_data(self, **kwargs): context["entries"] = entries_by_type return context - From 710812f3c034651134e8cdce4d3ba41e63ff82d4 Mon Sep 17 00:00:00 2001 From: Sam Oehlert Date: Thu, 20 Nov 2025 15:04:04 -0600 Subject: [PATCH 12/23] docs(serializer): update docstring to be more useful --- scram/route_manager/api/serializers.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scram/route_manager/api/serializers.py b/scram/route_manager/api/serializers.py index 9b051c97..52161ad0 100644 --- a/scram/route_manager/api/serializers.py +++ b/scram/route_manager/api/serializers.py @@ -53,11 +53,10 @@ class Meta: class IsBlockedSerializer(serializers.ModelSerializer): - """Map the serializer to the model via Meta.""" + """Map the serializer to the Entry model.""" class Meta: """Maps to the Entry model, but limits to the is_active field.""" - model = Entry fields = ["is_active"] From 6d8fb160cafcbc759159d202d9cefc25a60a0fb9 Mon Sep 17 00:00:00 2001 From: Sam Oehlert Date: Wed, 10 Dec 2025 15:32:33 -0600 Subject: [PATCH 13/23] docs(typo): fix typo in docstring --- scram/route_manager/api/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scram/route_manager/api/views.py b/scram/route_manager/api/views.py index 586a982f..c24a263d 100644 --- a/scram/route_manager/api/views.py +++ b/scram/route_manager/api/views.py @@ -85,7 +85,7 @@ def get_queryset(self): return queryset def list(self, request): - """OVerride the list function to just return a boolean instead of other metadata.""" + """Override the list function to just return a boolean instead of other metadata.""" entry = self.get_queryset().first() is_active = entry is not None From d0a243bd1a6269e8ee0a610d4aedd349c99a68ca Mon Sep 17 00:00:00 2001 From: Sam Oehlert Date: Fri, 12 Dec 2025 12:35:33 -0600 Subject: [PATCH 14/23] fix(missing-parameter): return a ValidationError when the ip parameter is missing it was always returning true before which is bad UX at the least --- scram/route_manager/api/views.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/scram/route_manager/api/views.py b/scram/route_manager/api/views.py index c24a263d..ffa402fa 100644 --- a/scram/route_manager/api/views.py +++ b/scram/route_manager/api/views.py @@ -10,6 +10,7 @@ from django.db.models import Q from drf_spectacular.utils import extend_schema from rest_framework import status, viewsets +from rest_framework.exceptions import ValidationError from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from simple_history.utils import update_change_reason @@ -79,9 +80,11 @@ class IsBlockedViewSet(viewsets.ReadOnlyModelViewSet): def get_queryset(self): """Focus queryset on active routes.""" queryset = Entry.objects.filter(is_active=True) - ip_address = self.request.query_params.get("ip") - if ip_address: - queryset = queryset.filter(route__route=ip_address) + ip = self.request.query_params.get("ip") + if not ip: + raise ValidationError(detail={"error": "ip parameter is required"}) + else: + queryset = queryset.filter(route__route=ip) return queryset def list(self, request): From ccb72b180e0eb1ca37443dcb45369f41e8a28997 Mon Sep 17 00:00:00 2001 From: Sam Oehlert Date: Fri, 12 Dec 2025 14:08:39 -0600 Subject: [PATCH 15/23] feat(response-fields): add route to the response fields so we can show which IP is active when searching a larger subnet --- scram/route_manager/api/serializers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scram/route_manager/api/serializers.py b/scram/route_manager/api/serializers.py index 52161ad0..719af249 100644 --- a/scram/route_manager/api/serializers.py +++ b/scram/route_manager/api/serializers.py @@ -54,11 +54,12 @@ class Meta: class IsBlockedSerializer(serializers.ModelSerializer): """Map the serializer to the Entry model.""" + route = serializers.StringRelatedField(source="route.route") class Meta: """Maps to the Entry model, but limits to the is_active field.""" model = Entry - fields = ["is_active"] + fields = ["is_active", "route"] class EntrySerializer(serializers.HyperlinkedModelSerializer): From a6aaafde8d62c544b2b587292d18211a62461629 Mon Sep 17 00:00:00 2001 From: Sam Oehlert Date: Fri, 12 Dec 2025 14:16:22 -0600 Subject: [PATCH 16/23] refactor(is_blocked-responses): fix a few issues. 1) accept cidrs and search inside of them 2) add an informational warning if we had to no rmalize the IP as a UX thing 3) make sure to include the actual IP with the response especially important if we return a lit --- scram/route_manager/api/views.py | 45 +++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/scram/route_manager/api/views.py b/scram/route_manager/api/views.py index ffa402fa..bc9aca46 100644 --- a/scram/route_manager/api/views.py +++ b/scram/route_manager/api/views.py @@ -77,22 +77,47 @@ class IsBlockedViewSet(viewsets.ReadOnlyModelViewSet): permission_classes = (AllowAny,) http_method_names = ["get"] + normalization_warning: str | None + normalized_cidr_for_response: ipaddress.IPv4Network | ipaddress.IPv6Network | None + def get_queryset(self): """Focus queryset on active routes.""" - queryset = Entry.objects.filter(is_active=True) - ip = self.request.query_params.get("ip") - if not ip: - raise ValidationError(detail={"error": "ip parameter is required"}) - else: - queryset = queryset.filter(route__route=ip) - return queryset + cidr = self.request.query_params.get("cidr") + if not cidr: + raise ValidationError(detail={"error": "cidr parameter is required"}) + try: + normalized_cidr = ipaddress.ip_network(cidr, strict=False) + except ValueError: + raise ValidationError(detail={'error': 'invalid ip address or network'}) + + self.normalization_warning = None + self.normalized_cidr_for_response = normalized_cidr + + if str(cidr) != str(normalized_cidr): + # save the warning so we can use it in the list response + self.normalization_warning = (f"Input CIDR '{cidr}' was not canonical and was normalized to " + f"'{str(normalized_cidr)}' for the search.") + + return Entry.objects.filter(route__route__net_contained_or_equal=normalized_cidr, is_active=True) def list(self, request): """Override the list function to just return a boolean instead of other metadata.""" - entry = self.get_queryset().first() - is_active = entry is not None + queryset = self.get_queryset() + warning_message = None + if hasattr(self, 'normalization_warning') and self.normalization_warning: + warning_message = self.normalization_warning + + if not queryset.exists() and hasattr(self, 'normalized_cidr_for_response'): + response_data = {"results": [{ + "is_active": False, + "route": str(self.normalized_cidr_for_response) + }]} + else: + serializer = self.get_serializer(queryset, many=True) + response_data = {"results": serializer.data} + response_data["warning"] = warning_message - return Response({"is_active": is_active}) + return Response(response_data) @extend_schema( From 48264d5d30d959cd28342cc667f6356705d9f089 Mon Sep 17 00:00:00 2001 From: Sam Oehlert Date: Fri, 12 Dec 2025 14:37:29 -0600 Subject: [PATCH 17/23] test(update): use the proper parameter name and look for the correct response data --- scram/route_manager/tests/test_api.py | 30 +++++++++++++++++---------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/scram/route_manager/tests/test_api.py b/scram/route_manager/tests/test_api.py index 198e1ab7..96a2a640 100644 --- a/scram/route_manager/tests/test_api.py +++ b/scram/route_manager/tests/test_api.py @@ -169,32 +169,40 @@ def setUp(self): def test_blocked_ipv4_returns_true(self): """Check that a blocked IPv4 returns is_active=true.""" - response = self.client.get(self.url, {"ip": "192.0.2.100"}) + response = self.client.get(self.url, {"cidr": "192.0.2.100"}) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data, {"is_active": True}) + self.assertEqual(len(response.data["results"]), 1) + self.assertEqual(response.data["results"][0]["is_active"], True) + self.assertEqual(response.data["results"][0]["route"], "192.0.2.100/32") def test_blocked_ipv6_returns_true(self): """Check that a blocked IPv6 returns is_active=true.""" - response = self.client.get(self.url, {"ip": "2001:db8::1"}) + response = self.client.get(self.url, {"cidr": "2001:db8::1"}) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data, {"is_active": True}) + self.assertEqual(len(response.data["results"]), 1) + self.assertEqual(response.data["results"][0]["is_active"], True) + self.assertEqual(response.data["results"][0]["route"], "2001:db8::1/128") def test_inactive_entry_ipv4_returns_false(self): """Check that an inactive entry returns is_active=false.""" - response = self.client.get(self.url, {"ip": "192.0.2.200"}) + response = self.client.get(self.url, {"cidr": "192.0.2.200"}) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data, {"is_active": False}) + self.assertEqual(len(response.data["results"]), 1) + self.assertEqual(response.data["results"][0]["is_active"], False) + self.assertEqual(response.data["results"][0]["route"], "192.0.2.200/32") def test_inactive_entry_ipv6_returns_false(self): """Check that an inactive entry returns is_active=false.""" - response = self.client.get(self.url, {"ip": "2001:db8::5"}) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data, {"is_active": False}) + response = self.client.get(self.url, {"cidr": "2001:db8::5"}) + self.assertEqual(len(response.data["results"]), 1) + self.assertEqual(response.data["results"][0]["is_active"], False) + self.assertEqual(response.data["results"][0]["route"], "2001:db8::5/128") def test_unauthenticated_access_allowed(self): """Ensure unauthenticated clients can check if IPs are blocked.""" # Logout any authenticated user self.client.logout() - response = self.client.get(self.url, {"ip": "192.0.2.100"}) + response = self.client.get(self.url, {"cidr": "192.0.2.100"}) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data, {"is_active": True}) + self.assertEqual(len(response.data["results"]), 1) + self.assertEqual(response.data["results"][0]["is_active"], True) From 0025b16a964a52f0f10124493e4706da2d7afbf5 Mon Sep 17 00:00:00 2001 From: Sam Oehlert Date: Fri, 12 Dec 2025 14:43:17 -0600 Subject: [PATCH 18/23] style(ruff): fixing ruff errors --- scram/route_manager/api/views.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/scram/route_manager/api/views.py b/scram/route_manager/api/views.py index bc9aca46..ec107fb4 100644 --- a/scram/route_manager/api/views.py +++ b/scram/route_manager/api/views.py @@ -88,7 +88,7 @@ def get_queryset(self): try: normalized_cidr = ipaddress.ip_network(cidr, strict=False) except ValueError: - raise ValidationError(detail={'error': 'invalid ip address or network'}) + raise ValidationError(detail={"error": "invalid ip address or network"}) from None self.normalization_warning = None self.normalized_cidr_for_response = normalized_cidr @@ -96,18 +96,18 @@ def get_queryset(self): if str(cidr) != str(normalized_cidr): # save the warning so we can use it in the list response self.normalization_warning = (f"Input CIDR '{cidr}' was not canonical and was normalized to " - f"'{str(normalized_cidr)}' for the search.") + f"'{normalized_cidr!s}' for the search.") - return Entry.objects.filter(route__route__net_contained_or_equal=normalized_cidr, is_active=True) + return Entry.objects.filter(route__route__net_contained_or_equal=normalized_cidr, is_active=True) def list(self, request): """Override the list function to just return a boolean instead of other metadata.""" queryset = self.get_queryset() warning_message = None - if hasattr(self, 'normalization_warning') and self.normalization_warning: + if hasattr(self, "normalization_warning") and self.normalization_warning: warning_message = self.normalization_warning - if not queryset.exists() and hasattr(self, 'normalized_cidr_for_response'): + if not queryset.exists() and hasattr(self, "normalized_cidr_for_response"): response_data = {"results": [{ "is_active": False, "route": str(self.normalized_cidr_for_response) From 0761a439fd16c8855d1d01965e293357e493c438 Mon Sep 17 00:00:00 2001 From: Sam Oehlert Date: Fri, 12 Dec 2025 15:07:41 -0600 Subject: [PATCH 19/23] ci(secret_key): try to hardcode a secret key to avoid the recursion error trying to create on in github actions --- config/settings/test.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/config/settings/test.py b/config/settings/test.py index e1201960..04cd519f 100644 --- a/config/settings/test.py +++ b/config/settings/test.py @@ -1,6 +1,5 @@ """With these settings, tests run faster.""" -from django.core.management.utils import get_random_secret_key from .base import * # noqa from .base import env @@ -10,7 +9,7 @@ # https://docs.djangoproject.com/en/dev/ref/settings/#secret-key SECRET_KEY = env( "DJANGO_SECRET_KEY", - default=get_random_secret_key(), + "adummysecretkeyforCI" # gitleaks:allow ) # https://docs.djangoproject.com/en/dev/ref/settings/#test-runner TEST_RUNNER = "django.test.runner.DiscoverRunner" From bdaeaf6ecee2371fb69238b90752ebb2e3877071 Mon Sep 17 00:00:00 2001 From: Sam Oehlert Date: Fri, 12 Dec 2025 15:12:13 -0600 Subject: [PATCH 20/23] style(ruff): apparently my ruff doesnt find the same as the stuff in CI --- config/settings/test.py | 3 +-- scram/route_manager/api/serializers.py | 2 ++ scram/route_manager/api/views.py | 10 ++++------ 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/config/settings/test.py b/config/settings/test.py index 04cd519f..b8ea1f76 100644 --- a/config/settings/test.py +++ b/config/settings/test.py @@ -1,6 +1,5 @@ """With these settings, tests run faster.""" - from .base import * # noqa from .base import env @@ -9,7 +8,7 @@ # https://docs.djangoproject.com/en/dev/ref/settings/#secret-key SECRET_KEY = env( "DJANGO_SECRET_KEY", - "adummysecretkeyforCI" # gitleaks:allow + "adummysecretkeyforCI", # gitleaks:allow ) # https://docs.djangoproject.com/en/dev/ref/settings/#test-runner TEST_RUNNER = "django.test.runner.DiscoverRunner" diff --git a/scram/route_manager/api/serializers.py b/scram/route_manager/api/serializers.py index 719af249..e2c23fb6 100644 --- a/scram/route_manager/api/serializers.py +++ b/scram/route_manager/api/serializers.py @@ -54,10 +54,12 @@ class Meta: class IsBlockedSerializer(serializers.ModelSerializer): """Map the serializer to the Entry model.""" + route = serializers.StringRelatedField(source="route.route") class Meta: """Maps to the Entry model, but limits to the is_active field.""" + model = Entry fields = ["is_active", "route"] diff --git a/scram/route_manager/api/views.py b/scram/route_manager/api/views.py index ec107fb4..c0a6dc0e 100644 --- a/scram/route_manager/api/views.py +++ b/scram/route_manager/api/views.py @@ -95,8 +95,9 @@ def get_queryset(self): if str(cidr) != str(normalized_cidr): # save the warning so we can use it in the list response - self.normalization_warning = (f"Input CIDR '{cidr}' was not canonical and was normalized to " - f"'{normalized_cidr!s}' for the search.") + self.normalization_warning = ( + f"Input CIDR '{cidr}' was not canonical and was normalized to '{normalized_cidr!s}' for the search." + ) return Entry.objects.filter(route__route__net_contained_or_equal=normalized_cidr, is_active=True) @@ -108,10 +109,7 @@ def list(self, request): warning_message = self.normalization_warning if not queryset.exists() and hasattr(self, "normalized_cidr_for_response"): - response_data = {"results": [{ - "is_active": False, - "route": str(self.normalized_cidr_for_response) - }]} + response_data = {"results": [{"is_active": False, "route": str(self.normalized_cidr_for_response)}]} else: serializer = self.get_serializer(queryset, many=True) response_data = {"results": serializer.data} From 4aa754b96fd70ba930f7bed0747c2e49ed2091b6 Mon Sep 17 00:00:00 2001 From: Sam Oehlert Date: Fri, 12 Dec 2025 15:23:46 -0600 Subject: [PATCH 21/23] ci(secret_key): remove hard coded key as that didnt fix it either --- config/settings/test.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/config/settings/test.py b/config/settings/test.py index b8ea1f76..e1201960 100644 --- a/config/settings/test.py +++ b/config/settings/test.py @@ -1,5 +1,7 @@ """With these settings, tests run faster.""" +from django.core.management.utils import get_random_secret_key + from .base import * # noqa from .base import env @@ -8,7 +10,7 @@ # https://docs.djangoproject.com/en/dev/ref/settings/#secret-key SECRET_KEY = env( "DJANGO_SECRET_KEY", - "adummysecretkeyforCI", # gitleaks:allow + default=get_random_secret_key(), ) # https://docs.djangoproject.com/en/dev/ref/settings/#test-runner TEST_RUNNER = "django.test.runner.DiscoverRunner" From abc5c8990da05e818cbfbd330d83e6c5e011d34c Mon Sep 17 00:00:00 2001 From: Sam Oehlert Date: Mon, 15 Dec 2025 08:47:17 -0600 Subject: [PATCH 22/23] refactor(warning-message): remove extraneous warning message code also continue renaming is_blocked to is_active --- scram/route_manager/api/views.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/scram/route_manager/api/views.py b/scram/route_manager/api/views.py index c0a6dc0e..b5d3d6f8 100644 --- a/scram/route_manager/api/views.py +++ b/scram/route_manager/api/views.py @@ -22,7 +22,7 @@ ClientSerializer, EntrySerializer, IgnoreEntrySerializer, - IsBlockedSerializer, + IsActiveSerializer, ) channel_layer = get_channel_layer() @@ -70,10 +70,10 @@ class ClientViewSet(viewsets.ModelViewSet): http_method_names = ["post"] -class IsBlockedViewSet(viewsets.ReadOnlyModelViewSet): +class IsActiveViewSet(viewsets.ReadOnlyModelViewSet): """Look up a route to see if SCRAM considers it active or deactivated.""" - serializer_class = IsBlockedSerializer + serializer_class = IsActiveSerializer permission_classes = (AllowAny,) http_method_names = ["get"] @@ -104,16 +104,13 @@ def get_queryset(self): def list(self, request): """Override the list function to just return a boolean instead of other metadata.""" queryset = self.get_queryset() - warning_message = None - if hasattr(self, "normalization_warning") and self.normalization_warning: - warning_message = self.normalization_warning if not queryset.exists() and hasattr(self, "normalized_cidr_for_response"): response_data = {"results": [{"is_active": False, "route": str(self.normalized_cidr_for_response)}]} else: serializer = self.get_serializer(queryset, many=True) response_data = {"results": serializer.data} - response_data["warning"] = warning_message + response_data["warning"] = self.normalization_warning return Response(response_data) From 011a99f859be8fd410b87d726d996c2dde3978fa Mon Sep 17 00:00:00 2001 From: Sam Oehlert Date: Mon, 15 Dec 2025 08:47:52 -0600 Subject: [PATCH 23/23] refactor(is-active): rename is_blocked to is_active to better match the rest of SCRAM --- config/api_router.py | 4 ++-- scram/route_manager/api/serializers.py | 4 ++-- scram/route_manager/tests/test_api.py | 26 +++++++++++++------------- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/config/api_router.py b/config/api_router.py index 9f163f29..e7f0ffbf 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -7,7 +7,7 @@ ClientViewSet, EntryViewSet, IgnoreEntryViewSet, - IsBlockedViewSet, + IsActiveViewSet, ) from scram.users.api.views import UserViewSet @@ -18,7 +18,7 @@ router.register("register_client", ClientViewSet) router.register("entries", EntryViewSet) router.register("ignore_entries", IgnoreEntryViewSet) -router.register("is_blocked", IsBlockedViewSet, "is_blocked") +router.register("is_active", IsActiveViewSet, "is_active") app_name = "api" urlpatterns = router.urls diff --git a/scram/route_manager/api/serializers.py b/scram/route_manager/api/serializers.py index e2c23fb6..2896b73c 100644 --- a/scram/route_manager/api/serializers.py +++ b/scram/route_manager/api/serializers.py @@ -52,13 +52,13 @@ class Meta: fields = ["hostname", "uuid"] -class IsBlockedSerializer(serializers.ModelSerializer): +class IsActiveSerializer(serializers.ModelSerializer): """Map the serializer to the Entry model.""" route = serializers.StringRelatedField(source="route.route") class Meta: - """Maps to the Entry model, but limits to the is_active field.""" + """Maps to the Entry model, but limits to the the appropriate fields.""" model = Entry fields = ["is_active", "route"] diff --git a/scram/route_manager/tests/test_api.py b/scram/route_manager/tests/test_api.py index 96a2a640..dd980924 100644 --- a/scram/route_manager/tests/test_api.py +++ b/scram/route_manager/tests/test_api.py @@ -127,12 +127,12 @@ def test_unauthenticated_users_have_no_list_access(self): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) -class TestIsBlocked(APITestCase): - """Test the is_blocked endpoint.""" +class TestIsActive(APITestCase): + """Test the is_active endpoint.""" def setUp(self): """Set up test data.""" - self.url = reverse("api:v1:is_blocked-list") + self.url = reverse("api:v1:is_active-list") self.authorized_client = Client.objects.create( hostname="authorized_client.es.net", uuid="0e7e1cbd-7d73-4968-bc4b-ce3265dc2fd3", @@ -141,18 +141,18 @@ def setUp(self): self.authorized_client.authorized_actiontypes.set([1]) self.actiontype, _ = ActionType.objects.get_or_create(pk=1, defaults={"name": "block"}) - # Create some blocked entries + # Create some active entries - # Active blocked IPv4 + # Active IPv4 route_v4 = Route.objects.create(route="192.0.2.100") Entry.objects.create( - route=route_v4, is_active=True, comment="test block", who="test", actiontype=self.actiontype + route=route_v4, is_active=True, comment="test active", who="test", actiontype=self.actiontype ) - # Active blocked IPv6 + # Active IPv6 route_v6 = Route.objects.create(route="2001:db8::1") Entry.objects.create( - route=route_v6, is_active=True, comment="test block v6", who="test", actiontype=self.actiontype + route=route_v6, is_active=True, comment="test active v6", who="test", actiontype=self.actiontype ) # Deactivated IPv4 entry @@ -167,16 +167,16 @@ def setUp(self): route=route_inactive, is_active=False, comment="inactive", who="test", actiontype=self.actiontype ) - def test_blocked_ipv4_returns_true(self): - """Check that a blocked IPv4 returns is_active=true.""" + def test_active_ipv4_returns_true(self): + """Check that an active IPv4 returns is_active=true.""" response = self.client.get(self.url, {"cidr": "192.0.2.100"}) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data["results"]), 1) self.assertEqual(response.data["results"][0]["is_active"], True) self.assertEqual(response.data["results"][0]["route"], "192.0.2.100/32") - def test_blocked_ipv6_returns_true(self): - """Check that a blocked IPv6 returns is_active=true.""" + def test_active_ipv6_returns_true(self): + """Check that an active IPv6 returns is_active=true.""" response = self.client.get(self.url, {"cidr": "2001:db8::1"}) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data["results"]), 1) @@ -199,7 +199,7 @@ def test_inactive_entry_ipv6_returns_false(self): self.assertEqual(response.data["results"][0]["route"], "2001:db8::5/128") def test_unauthenticated_access_allowed(self): - """Ensure unauthenticated clients can check if IPs are blocked.""" + """Ensure unauthenticated clients can check if IPs are active.""" # Logout any authenticated user self.client.logout() response = self.client.get(self.url, {"cidr": "192.0.2.100"})