diff --git a/src/aap_eda/api/blacklist.py b/src/aap_eda/api/blacklist.py new file mode 100644 index 000000000..859ed8dc7 --- /dev/null +++ b/src/aap_eda/api/blacklist.py @@ -0,0 +1,128 @@ +# Copyright 2026 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import ipaddress +import logging + +from rest_framework.exceptions import AuthenticationFailed + +logger = logging.getLogger(__name__) + +MAX_BLOCKED_IPS = 1000 + + +def normalize_ip(ip_str: str) -> str: + """Normalize an IP address string. + + Converts IPv4-mapped IPv6 addresses (e.g. ::ffff:10.0.0.1) + to their IPv4 form so allowlist lookups match regardless of + how the proxy reports the client IP. + """ + try: + addr = ipaddress.ip_address(ip_str.strip()) + except ValueError: + return ip_str.strip() + if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped: + return str(addr.ipv4_mapped) + return str(addr) + + +def ip_in_allowlist(client_ip: str, allowed_ips: set) -> bool: + """Check if an IP matches any entry in the allowlist. + + Supports both individual IPs and CIDR ranges. + """ + if client_ip in allowed_ips: + return True + try: + addr = ipaddress.ip_address(client_ip) + except ValueError: + return False + for entry in allowed_ips: + if "/" in entry: + try: + if addr in ipaddress.ip_network(entry, strict=False): + return True + except ValueError: + continue + return False + + +class BlacklistManager: + """Allowlist-based IP policy for event streams. + + When an organization has configured allowed_ips, only those + IPs may post events. IPs that are rejected are recorded in + blocked_ips for admin visibility — admins can then promote + a blocked IP to the allowlist or remove it entirely. + + When no EventStreamSetting row exists for the org, or the + allowed_ips list is empty, all IPs are permitted. + """ + + def check_ip_policy(self, client_ip: str, org_id: int) -> None: + """Check if the IP is allowed for this organization. + + Raises AuthenticationFailed if the org has a non-empty + allowlist and the IP is not in it. + """ + from aap_eda.services.event_stream_settings_cache import ( + get_org_settings, + ) + + org_settings = get_org_settings(org_id) + if org_settings is None: + return + + normalized = normalize_ip(client_ip) + + if org_settings["allowed_ips"] and not ip_in_allowlist( + normalized, org_settings["allowed_ips"] + ): + self.record_blocked_ip(normalized, org_id) + raise AuthenticationFailed("IP address not in allowlist") + + def record_blocked_ip(self, client_ip: str, org_id: int) -> None: + """Add an IP to the org's blocked_ips list for visibility. + + Only adds the IP if it is not already tracked. Caps the + list at MAX_BLOCKED_IPS to prevent unbounded growth. + """ + from aap_eda.core.models import EventStreamSetting + + normalized = normalize_ip(client_ip) + + try: + setting = EventStreamSetting.objects.get(organization_id=org_id) + except EventStreamSetting.DoesNotExist: + return + + if normalized in setting.blocked_ips: + return + + if len(setting.blocked_ips) >= MAX_BLOCKED_IPS: + logger.warning( + "Blocked IPs cap (%d) reached for org %s", + MAX_BLOCKED_IPS, + org_id, + ) + return + + setting.blocked_ips = [*setting.blocked_ips, normalized] + setting.save(update_fields=["blocked_ips", "modified_at"]) + logger.info( + "Recorded blocked IP %s for org %s", + normalized, + org_id, + ) diff --git a/src/aap_eda/api/event_stream_authentication.py b/src/aap_eda/api/event_stream_authentication.py index a2e82bd84..a8d8a84de 100644 --- a/src/aap_eda/api/event_stream_authentication.py +++ b/src/aap_eda/api/event_stream_authentication.py @@ -33,6 +33,7 @@ from aap_eda.core.enums import SignatureEncodingType from aap_eda.core.utils.credentials import validate_x509_subject_match +from aap_eda.core.utils.crypto import timing_safe_compare logger = logging.getLogger(__name__) DEFAULT_TIMEOUT = 30 @@ -81,7 +82,7 @@ def authenticate(self, body: bytes): logger.warning(message) raise AuthenticationFailed(message) - if not hmac.compare_digest(expected_signature, self.signature): + if not timing_safe_compare(expected_signature, self.signature): message = "Signature mismatch, check your payload and secret" logger.warning(message) raise AuthenticationFailed(message) @@ -96,7 +97,7 @@ class TokenAuthentication(EventStreamAuthentication): def authenticate(self, _body=None): """Handle Token authentication.""" - if self.token != _token_sans_bearer(self.value): + if not timing_safe_compare(self.token, _token_sans_bearer(self.value)): message = "Token mismatch, check your token" logger.warning(message) raise AuthenticationFailed(message) @@ -152,9 +153,9 @@ def authenticate(self, _body=None): if self.authorization.startswith("Basic"): auth_str = self.authorization.split("Basic ")[1] - user_pass = f"{self.username}:{self.password}" + user_pass = "%s:%s" % (self.username, self.password) b64_value = base64.b64encode(user_pass.encode()).decode() - if auth_str != b64_value: + if not timing_safe_compare(auth_str, b64_value): message = "Credential mismatch" logger.warning(message) raise AuthenticationFailed(message) diff --git a/src/aap_eda/api/filters/__init__.py b/src/aap_eda/api/filters/__init__.py index 8e18e1e11..23220d348 100644 --- a/src/aap_eda/api/filters/__init__.py +++ b/src/aap_eda/api/filters/__init__.py @@ -22,6 +22,7 @@ from .decision_environment import DecisionEnvironmentFilter from .eda_credential import EdaCredentialFilter from .event_stream import EventStreamFilter +from .event_stream_setting import EventStreamSettingFilter from .organization import OrganizationFilter from .project import ProjectFilter from .rulebook import RulebookFilter @@ -52,4 +53,6 @@ "OrganizationTeamFilter", # EventStream "EventStreamFilter", + # EventStreamSetting + "EventStreamSettingFilter", ) diff --git a/src/aap_eda/api/filters/event_stream_setting.py b/src/aap_eda/api/filters/event_stream_setting.py new file mode 100644 index 000000000..7870b3a0f --- /dev/null +++ b/src/aap_eda/api/filters/event_stream_setting.py @@ -0,0 +1,23 @@ +# Copyright 2026 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import django_filters + +from aap_eda.core import models + + +class EventStreamSettingFilter(django_filters.FilterSet): + class Meta: + model = models.EventStreamSetting + fields = ["organization"] diff --git a/src/aap_eda/api/serializers/__init__.py b/src/aap_eda/api/serializers/__init__.py index 9c072c486..bf144f359 100644 --- a/src/aap_eda/api/serializers/__init__.py +++ b/src/aap_eda/api/serializers/__init__.py @@ -52,6 +52,11 @@ EdaCredentialUpdateSerializer, ) from .event_stream import EventStreamInSerializer, EventStreamOutSerializer +from .event_stream_setting import ( + EventStreamSettingCreateSerializer, + EventStreamSettingOutSerializer, + RemoveBlockedIpsSerializer, +) from .organization import ( OrganizationCreateSerializer, OrganizationRefSerializer, @@ -155,4 +160,8 @@ # event streams "EventStreamInSerializer", "EventStreamOutSerializer", + # event stream settings + "EventStreamSettingCreateSerializer", + "EventStreamSettingOutSerializer", + "RemoveBlockedIpsSerializer", ) diff --git a/src/aap_eda/api/serializers/event_stream_setting.py b/src/aap_eda/api/serializers/event_stream_setting.py new file mode 100644 index 000000000..b2a42ac19 --- /dev/null +++ b/src/aap_eda/api/serializers/event_stream_setting.py @@ -0,0 +1,158 @@ +# Copyright 2026 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import ipaddress + +from rest_framework import serializers + +from aap_eda.api.serializers.fields.basic_user import BasicUserFieldSerializer +from aap_eda.api.serializers.organization import OrganizationRefSerializer +from aap_eda.api.serializers.user import BasicUserSerializer +from aap_eda.core import models, validators + +MAX_IPS_PER_LIST = 255 + + +def _validate_ip_list(value): + if len(value) > MAX_IPS_PER_LIST: + raise serializers.ValidationError( + f"Maximum {MAX_IPS_PER_LIST} entries allowed." + ) + from aap_eda.api.blacklist import normalize_ip + + normalized = [] + for entry in value: + entry = entry.strip() + if "/" in entry: + try: + net = ipaddress.ip_network(entry, strict=False) + normalized.append(str(net)) + except ValueError: + raise serializers.ValidationError( + f"'{entry}' is not a valid CIDR range." + ) + else: + try: + ipaddress.ip_address(entry) + except ValueError: + raise serializers.ValidationError( + f"'{entry}' is not a valid IP address." + ) + normalized.append(normalize_ip(entry)) + return normalized + + +class EventStreamSettingCreateSerializer( + serializers.ModelSerializer, +): + organization_id = serializers.IntegerField( + required=True, + allow_null=False, + validators=[validators.check_if_organization_exists], + error_messages={"null": "Organization is required."}, + ) + allowed_ips = serializers.ListField( + child=serializers.CharField(max_length=45), + required=False, + default=list, + ) + blocked_ips = serializers.ListField( + child=serializers.CharField(max_length=45), + required=False, + default=list, + ) + + class Meta: + model = models.EventStreamSetting + fields = [ + "organization_id", + "allowed_ips", + "blocked_ips", + ] + + def validate_organization_id(self, value): + if ( + not self.instance + and models.EventStreamSetting.objects.filter( + organization_id=value + ).exists() + ): + raise serializers.ValidationError( + "Settings already exist for this organization." + ) + return value + + def validate_allowed_ips(self, value): + return _validate_ip_list(value) + + def validate_blocked_ips(self, value): + return _validate_ip_list(value) + + def update(self, instance, validated_data): + new_allowed = validated_data.get("allowed_ips") + if new_allowed is not None: + added_ips = set(new_allowed) - set(instance.allowed_ips) + if added_ips and instance.blocked_ips: + instance.blocked_ips = [ + ip for ip in instance.blocked_ips if ip not in added_ips + ] + return super().update(instance, validated_data) + + +class EventStreamSettingOutSerializer( + serializers.ModelSerializer, +): + organization = serializers.SerializerMethodField() + created_by = BasicUserFieldSerializer() + modified_by = BasicUserFieldSerializer() + + class Meta: + model = models.EventStreamSetting + read_only_fields = [ + "id", + "created_at", + "modified_at", + ] + fields = [ + "organization", + "allowed_ips", + "blocked_ips", + "created_by", + "modified_by", + *read_only_fields, + ] + + def get_organization(self, obj): + return ( + OrganizationRefSerializer(obj.organization).data + if obj.organization + else None + ) + + def to_representation(self, instance): + result = super().to_representation(instance) + result["created_by"] = BasicUserSerializer(instance.created_by).data + result["modified_by"] = BasicUserSerializer(instance.modified_by).data + return result + + +class RemoveBlockedIpsSerializer(serializers.Serializer): + ips = serializers.ListField( + child=serializers.CharField(max_length=45), + required=True, + help_text="IPs to remove from the blocked list.", + ) + + def validate_ips(self, value): + return _validate_ip_list(value) diff --git a/src/aap_eda/api/urls.py b/src/aap_eda/api/urls.py index 83c673d29..e84c745ae 100644 --- a/src/aap_eda/api/urls.py +++ b/src/aap_eda/api/urls.py @@ -56,6 +56,7 @@ router.register("organizations", views.OrganizationViewSet) router.register("teams", views.TeamViewSet) router.register("event-streams", views.EventStreamViewSet) +router.register("event-stream-settings", views.EventStreamSettingViewSet) router.register( "external_event_stream", views.ExternalEventStreamViewSet, diff --git a/src/aap_eda/api/views/__init__.py b/src/aap_eda/api/views/__init__.py index 3b61fdf77..5b0540b5c 100644 --- a/src/aap_eda/api/views/__init__.py +++ b/src/aap_eda/api/views/__init__.py @@ -20,6 +20,7 @@ from .decision_environment import DecisionEnvironmentViewSet from .eda_credential import EdaCredentialViewSet from .event_stream import EventStreamViewSet +from .event_stream_setting import EventStreamSettingViewSet from .external_event_stream import ExternalEventStreamViewSet from .organization import OrganizationViewSet from .project import ProjectViewSet @@ -61,6 +62,8 @@ "ConfigView", # Event stream "EventStreamViewSet", + # Event stream settings + "EventStreamSettingViewSet", # External event stream "ExternalEventStreamViewSet", ) diff --git a/src/aap_eda/api/views/event_stream_setting.py b/src/aap_eda/api/views/event_stream_setting.py new file mode 100644 index 000000000..bd10d6328 --- /dev/null +++ b/src/aap_eda/api/views/event_stream_setting.py @@ -0,0 +1,180 @@ +# Copyright 2026 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""EventStreamSetting API — per-org IP security settings.""" + +import logging + +from django_filters import rest_framework as defaultfilters +from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework import mixins, status, viewsets +from rest_framework.decorators import action +from rest_framework.response import Response + +from aap_eda.api import filters, serializers +from aap_eda.api.views.mixins import ( + CreateModelMixin, + PartialUpdateOnlyModelMixin, + ResponseSerializerMixin, +) +from aap_eda.core import models +from aap_eda.core.enums import ResourceType +from aap_eda.services.event_stream_settings_cache import ( + invalidate_org_settings, +) + +logger = logging.getLogger(__name__) + + +class EventStreamSettingViewSet( + ResponseSerializerMixin, + CreateModelMixin, + PartialUpdateOnlyModelMixin, + mixins.RetrieveModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet, +): + queryset = models.EventStreamSetting.objects.order_by("-created_at") + filter_backends = (defaultfilters.DjangoFilterBackend,) + filterset_class = filters.EventStreamSettingFilter + rbac_resource_type = ResourceType.EVENT_STREAM_SETTING + + def get_serializer_class(self): + if self.action in ("create", "partial_update"): + return serializers.EventStreamSettingCreateSerializer + return serializers.EventStreamSettingOutSerializer + + def get_response_serializer_class(self): + return serializers.EventStreamSettingOutSerializer + + def filter_queryset(self, queryset): + return super().filter_queryset( + queryset.model.access_qs(self.request.user, queryset=queryset) + ) + + @extend_schema( + description="Get event stream settings by id", + responses={ + status.HTTP_200_OK: OpenApiResponse( + serializers.EventStreamSettingOutSerializer, + description=("Return the event stream settings."), + ), + }, + ) + def retrieve(self, request, *args, **kwargs): + return super().retrieve(request, *args, **kwargs) + + @extend_schema( + description="List event stream settings", + responses={ + status.HTTP_200_OK: OpenApiResponse( + serializers.EventStreamSettingOutSerializer(many=True), + description=("Return a list of event stream settings."), + ), + }, + ) + def list(self, request, *args, **kwargs): + return super().list(request, *args, **kwargs) + + @extend_schema( + request=serializers.EventStreamSettingCreateSerializer, + responses={ + status.HTTP_201_CREATED: OpenApiResponse( + serializers.EventStreamSettingOutSerializer, + description=("Return the new event stream settings."), + ), + }, + ) + def create(self, request, *args, **kwargs): + return super().create(request, *args, **kwargs) + + @extend_schema( + request=serializers.EventStreamSettingCreateSerializer, + responses={ + status.HTTP_200_OK: OpenApiResponse( + serializers.EventStreamSettingOutSerializer, + description=("Return the updated event stream settings."), + ), + }, + ) + def partial_update(self, request, *args, **kwargs): + return super().partial_update(request, *args, **kwargs) + + @extend_schema( + description=("Remove specific IPs from the blocked list."), + request=serializers.RemoveBlockedIpsSerializer, + responses={ + status.HTTP_200_OK: OpenApiResponse( + serializers.EventStreamSettingOutSerializer, + description=("Return the updated event stream settings."), + ), + }, + ) + @action( + detail=True, + methods=["post"], + url_path="remove-blocked", + ) + def remove_blocked(self, request, pk=None): + """Remove specific IPs from the blocked list.""" + setting = self.get_object() + sz = serializers.RemoveBlockedIpsSerializer(data=request.data) + sz.is_valid(raise_exception=True) + ips_to_remove = set(sz.validated_data["ips"]) + + setting.blocked_ips = [ + ip for ip in setting.blocked_ips if ip not in ips_to_remove + ] + setting.save(update_fields=["blocked_ips", "modified_at"]) + invalidate_org_settings(setting.organization_id) + + logger.info( + "Removed %d IPs from blocked list for org %s", + len(ips_to_remove), + setting.organization_id, + ) + return Response( + serializers.EventStreamSettingOutSerializer(setting).data, + status=status.HTTP_200_OK, + ) + + @extend_schema( + description="Clear all IPs from the blocked list.", + request=None, + responses={ + status.HTTP_200_OK: OpenApiResponse( + serializers.EventStreamSettingOutSerializer, + description=("Return the updated event stream settings."), + ), + }, + ) + @action( + detail=True, + methods=["post"], + url_path="clear-blocked", + ) + def clear_blocked(self, request, pk=None): + """Clear all blocked IPs.""" + setting = self.get_object() + setting.blocked_ips = [] + setting.save(update_fields=["blocked_ips", "modified_at"]) + invalidate_org_settings(setting.organization_id) + + logger.info( + "Cleared all blocked IPs for org %s", + setting.organization_id, + ) + return Response( + serializers.EventStreamSettingOutSerializer(setting).data, + status=status.HTTP_200_OK, + ) diff --git a/src/aap_eda/api/views/external_event_stream.py b/src/aap_eda/api/views/external_event_stream.py index 44b05a56a..58ab5ce8e 100644 --- a/src/aap_eda/api/views/external_event_stream.py +++ b/src/aap_eda/api/views/external_event_stream.py @@ -34,6 +34,7 @@ from rest_framework.permissions import AllowAny from rest_framework.response import Response +from aap_eda.api.blacklist import BlacklistManager from aap_eda.api.event_stream_authentication import ( BasicAuthentication, EcdsaAuthentication, @@ -52,6 +53,7 @@ logger = logging.getLogger(__name__) UNSAFE_HEADER_KEYS = {"X-Trusted-Proxy", "X-Forwarded-For", "X-Real-IP"} +blacklist_manager = BlacklistManager() class ExternalEventStreamViewSet(viewsets.GenericViewSet): @@ -306,17 +308,41 @@ def _handle_auth(self, request, inputs): ) raise + def _get_client_ip(self, request): + """Return the normalized client IP from the request. + + Uses the rightmost X-Forwarded-For IP (appended by the + trusted proxy) when proxy validation is enabled, otherwise + falls back to REMOTE_ADDR. Normalizes IPv4-mapped IPv6 + addresses to IPv4 form for consistent allowlist matching. + """ + from aap_eda.api.blacklist import normalize_ip + + if settings.EVENT_STREAM_REQUIRE_TRUSTED_PROXY: + x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") + if x_forwarded_for: + return normalize_ip(x_forwarded_for.split(",")[-1]) + remote_addr = request.META.get("REMOTE_ADDR") + if not remote_addr: + raise AuthenticationFailed("Unable to determine client IP") + return normalize_ip(remote_addr) + @extend_schema(exclude=True) @action(detail=True, methods=["POST"], rbac_action=None) def post(self, request, *_args, **kwargs): """Handle posts from external vendors.""" + # Validate X-Trusted-Proxy header from Gateway/Envoy + self._validate_trusted_proxy_header(request) + + client_ip = self._get_client_ip(request) + try: self.event_stream = EventStream.objects.get(uuid=kwargs["pk"]) except (EventStream.DoesNotExist, ValidationError) as exc: raise ParseError("bad uuid specified") from exc - # Validate X-Trusted-Proxy header from Gateway/Envoy - self._validate_trusted_proxy_header(request) + org_id = self.event_stream.organization_id + blacklist_manager.check_ip_policy(client_ip, org_id) try: inputs = get_resolved_secrets(self.event_stream.eda_credential) @@ -338,7 +364,11 @@ def post(self, request, *_args, **kwargs): ) raise ParseError(message) - self._handle_auth(request, inputs) + try: + self._handle_auth(request, inputs) + except AuthenticationFailed: + blacklist_manager.record_blocked_ip(client_ip, org_id) + raise body = self._parse_body( request.headers.get("Content-Type", ""), request.body diff --git a/src/aap_eda/core/apps.py b/src/aap_eda/core/apps.py index ba63a5c22..4627f198f 100644 --- a/src/aap_eda/core/apps.py +++ b/src/aap_eda/core/apps.py @@ -28,6 +28,7 @@ class CoreConfig(AppConfig): def ready(self): # make sure we apply DAB decorations in case they are not yet imported from aap_eda.api.views import dab_decorate # noqa: F401 + from aap_eda.services import event_stream_settings_cache # noqa: F401 # Enable default dispatcher config. Workers may override this dispatcher_setup(settings.DISPATCHERD_DEFAULT_SETTINGS) diff --git a/src/aap_eda/core/enums.py b/src/aap_eda/core/enums.py index aa2e3a007..40ecc8a50 100644 --- a/src/aap_eda/core/enums.py +++ b/src/aap_eda/core/enums.py @@ -55,6 +55,7 @@ class ResourceType(DjangoStrEnum): ORGANIZATION = "organization" TEAM = "team" EVENT_STREAM = "event_stream" + EVENT_STREAM_SETTING = "event_stream_setting" CREDENTIAL_INPUT_SOURCE = "credential_input_source" diff --git a/src/aap_eda/core/migrations/0074_eventstreamsetting.py b/src/aap_eda/core/migrations/0074_eventstreamsetting.py new file mode 100644 index 000000000..96bd79f6d --- /dev/null +++ b/src/aap_eda/core/migrations/0074_eventstreamsetting.py @@ -0,0 +1,98 @@ +# Generated by Django 5.2 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0073_activation_k8s_pod_tolerations"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="EventStreamSetting", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_by", + models.ForeignKey( + default=None, + editable=False, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%s(class)s_created+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "modified_by", + models.ForeignKey( + default=None, + editable=False, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%s(class)s_modified+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "organization", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="event_stream_setting", + to="core.organization", + ), + ), + ( + "allowed_ips", + models.JSONField( + blank=True, + default=list, + help_text=( + "IP allowlist. Accepts individual IPs " + "and CIDR ranges (e.g. 192.30.252.0/22). " + "When non-empty, only matching IPs may " + "post events. Empty means all IPs are " + "allowed." + ), + ), + ), + ( + "blocked_ips", + models.JSONField( + blank=True, + default=list, + help_text=( + "IPs that attempted access and were " + "rejected. Auto-populated on failed " + "requests for admin visibility." + ), + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True), + ), + ( + "modified_at", + models.DateTimeField(auto_now=True), + ), + ], + options={ + "db_table": "core_event_stream_setting", + "ordering": ("-created_at",), + "default_permissions": ("add", "change", "view"), + }, + ), + ] diff --git a/src/aap_eda/core/models/__init__.py b/src/aap_eda/core/models/__init__.py index fba9fd3d6..8d34416a9 100644 --- a/src/aap_eda/core/models/__init__.py +++ b/src/aap_eda/core/models/__init__.py @@ -20,6 +20,7 @@ from .decision_environment import DecisionEnvironment from .eda_credential import EdaCredential from .event_stream import EventStream +from .event_stream_setting import EventStreamSetting from .job import ( ActivationInstanceJobInstance, Job, @@ -65,6 +66,7 @@ "Organization", "Team", "EventStream", + "EventStreamSetting", "Setting", ] @@ -77,6 +79,7 @@ Organization, Team, EventStream, + EventStreamSetting, parent_field_name="organization", ) permission_registry.register( diff --git a/src/aap_eda/core/models/event_stream_setting.py b/src/aap_eda/core/models/event_stream_setting.py new file mode 100644 index 000000000..8286c9f80 --- /dev/null +++ b/src/aap_eda/core/models/event_stream_setting.py @@ -0,0 +1,58 @@ +# Copyright 2026 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from django.db import models + +from .base import PrimordialModel + + +class EventStreamSetting(PrimordialModel): + """Per-organization IP security settings for event streams. + + Controls which IPs may post events (allowlist) and tracks + IPs that attempted access and were rejected (blocklist). + Admins can promote blocked IPs to the allowlist or remove + them from the blocklist entirely. + """ + + class Meta: + db_table = "core_event_stream_setting" + ordering = ("-created_at",) + default_permissions = ("add", "change", "view") + + organization = models.OneToOneField( + "Organization", + on_delete=models.CASCADE, + related_name="event_stream_setting", + ) + allowed_ips = models.JSONField( + default=list, + blank=True, + help_text=( + "IP allowlist. Accepts individual IPs and CIDR " + "ranges (e.g. 192.30.252.0/22). When non-empty, " + "only matching IPs may post events. " + "Empty means all IPs are allowed." + ), + ) + blocked_ips = models.JSONField( + default=list, + blank=True, + help_text=( + "IPs that attempted access and were rejected. " + "Auto-populated on failed requests for admin visibility." + ), + ) + created_at = models.DateTimeField(auto_now_add=True) + modified_at = models.DateTimeField(auto_now=True) diff --git a/src/aap_eda/core/utils/crypto/__init__.py b/src/aap_eda/core/utils/crypto/__init__.py index 56d762bcd..bd8b8febb 100644 --- a/src/aap_eda/core/utils/crypto/__init__.py +++ b/src/aap_eda/core/utils/crypto/__init__.py @@ -11,3 +11,18 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. + +import hmac + + +def timing_safe_compare(a: str, b: str) -> bool: + """Compare two strings in constant time using hmac.compare_digest. + + Python's hmac.compare_digest raises TypeError when either str + operand contains non-ASCII characters — not just on a str-vs-bytes + mismatch. Since Django provides HTTP header values as str with no + ASCII guarantee, callers comparing credentials from headers must + encode to bytes first. This function handles that conversion so + callers don't need to remember the footgun. + """ + return hmac.compare_digest(a.encode(), b.encode()) diff --git a/src/aap_eda/services/event_stream_settings_cache.py b/src/aap_eda/services/event_stream_settings_cache.py new file mode 100644 index 000000000..427e16916 --- /dev/null +++ b/src/aap_eda/services/event_stream_settings_cache.py @@ -0,0 +1,82 @@ +# Copyright 2026 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Per-organization event stream settings cache. + +Settings are cached for SETTINGS_CACHE_TTL seconds. A post_save +signal on EventStreamSetting invalidates the cache so changes +propagate immediately (within the same cache backend). +""" + +import logging +from typing import Any, Optional + +from django.core.cache import cache +from django.db.models.signals import post_save +from django.dispatch import receiver + +logger = logging.getLogger(__name__) + +SETTINGS_CACHE_PREFIX = "es_org_settings" +SETTINGS_CACHE_TTL = 300 +_CACHE_MISS = object() + + +def _cache_key(org_id: int) -> str: + return f"{SETTINGS_CACHE_PREFIX}:{org_id}" # noqa: E231 + + +def get_org_settings(org_id: int) -> Optional[dict]: + """Get per-org event stream settings, from cache or DB. + + Returns None if no settings row exists for this org + (meaning no IP restrictions are configured). + """ + key = _cache_key(org_id) + cached = cache.get(key, _CACHE_MISS) + if cached is not _CACHE_MISS: + return cached + + from aap_eda.core.models import EventStreamSetting + + try: + setting = EventStreamSetting.objects.get(organization_id=org_id) + data = { + "allowed_ips": set(setting.allowed_ips), + "blocked_ips": set(setting.blocked_ips), + } + except EventStreamSetting.DoesNotExist: + data = None + + cache.set(key, data, SETTINGS_CACHE_TTL) + return data + + +def invalidate_org_settings(org_id: int) -> None: + """Delete the cached settings for an organization.""" + cache.delete(_cache_key(org_id)) + + +@receiver(post_save, sender="core.EventStreamSetting") +def on_event_stream_setting_saved( + sender: Any, + instance: Any, + **kwargs: Any, +) -> None: + """Invalidate cache when settings are saved.""" + invalidate_org_settings(instance.organization_id) + logger.info( + "Invalidated event stream settings cache for org %s", + instance.organization_id, + ) diff --git a/src/aap_eda/settings/defaults.py b/src/aap_eda/settings/defaults.py index 754921f8e..d69d67103 100644 --- a/src/aap_eda/settings/defaults.py +++ b/src/aap_eda/settings/defaults.py @@ -238,6 +238,7 @@ # Set to False for local development without proxy: # export EDA_EVENT_STREAM_REQUIRE_TRUSTED_PROXY=False EVENT_STREAM_REQUIRE_TRUSTED_PROXY: bool = True + MAX_PG_NOTIFY_MESSAGE_SIZE: int = 6144 # Database credentials for the event streams user diff --git a/src/aap_eda/settings/testing_defaults.py b/src/aap_eda/settings/testing_defaults.py new file mode 100644 index 000000000..c47431ef2 --- /dev/null +++ b/src/aap_eda/settings/testing_defaults.py @@ -0,0 +1,13 @@ +# Copyright 2026 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/integration/api/conftest.py b/tests/integration/api/conftest.py index 0aae1b83c..4a8956008 100644 --- a/tests/integration/api/conftest.py +++ b/tests/integration/api/conftest.py @@ -1,9 +1,18 @@ """Shared test configuration for API integration tests.""" import pytest +from django.core.cache import cache from django.test.utils import override_settings +@pytest.fixture(autouse=True) +def clear_cache_for_tests(): + """Clear cache before and after each test.""" + cache.clear() + yield + cache.clear() + + @pytest.fixture(autouse=True) def disable_trusted_proxy_validation_for_tests(request): """Disable X-Trusted-Proxy header validation for all tests except diff --git a/tests/integration/api/test_event_stream_setting.py b/tests/integration/api/test_event_stream_setting.py new file mode 100644 index 000000000..260c11cdb --- /dev/null +++ b/tests/integration/api/test_event_stream_setting.py @@ -0,0 +1,377 @@ +# Copyright 2026 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import hashlib +import hmac +import secrets + +import pytest +from rest_framework import status +from rest_framework.renderers import JSONRenderer +from rest_framework.test import APIClient + +from aap_eda.core import models +from tests.integration.api.test_event_stream import ( + create_event_stream, + create_event_stream_credential, + event_stream_post_url, + get_default_test_org, +) +from tests.integration.constants import api_url_v1 + +SETTINGS_URL = f"{api_url_v1}/event-stream-settings" + + +@pytest.fixture +def default_event_stream_setting(default_organization): + return models.EventStreamSetting.objects.create( + organization=default_organization, + allowed_ips=["10.0.0.1", "10.0.0.2"], + blocked_ips=["192.168.1.100"], + ) + + +@pytest.mark.django_db +class TestEventStreamSettingCreate: + def test_create( + self, + admin_client: APIClient, + default_organization, + ): + response = admin_client.post( + f"{SETTINGS_URL}/", + data={ + "organization_id": default_organization.id, + "allowed_ips": ["10.0.0.1"], + }, + ) + assert response.status_code == status.HTTP_201_CREATED + assert response.data["allowed_ips"] == ["10.0.0.1"] + assert response.data["blocked_ips"] == [] + + def test_create_duplicate_org_rejected( + self, + admin_client: APIClient, + default_event_stream_setting, + default_organization, + ): + response = admin_client.post( + f"{SETTINGS_URL}/", + data={ + "organization_id": default_organization.id, + }, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_create_with_defaults( + self, + admin_client: APIClient, + default_organization, + ): + response = admin_client.post( + f"{SETTINGS_URL}/", + data={ + "organization_id": default_organization.id, + }, + ) + assert response.status_code == status.HTTP_201_CREATED + assert response.data["allowed_ips"] == [] + assert response.data["blocked_ips"] == [] + + +@pytest.mark.django_db +class TestEventStreamSettingRead: + def test_retrieve( + self, + admin_client: APIClient, + default_event_stream_setting, + ): + pk = default_event_stream_setting.id + response = admin_client.get(f"{SETTINGS_URL}/{pk}/") + assert response.status_code == status.HTTP_200_OK + assert response.data["allowed_ips"] == [ + "10.0.0.1", + "10.0.0.2", + ] + assert response.data["organization"] is not None + + def test_list( + self, + admin_client: APIClient, + default_event_stream_setting, + ): + response = admin_client.get(f"{SETTINGS_URL}/") + assert response.status_code == status.HTTP_200_OK + assert len(response.data["results"]) == 1 + + +@pytest.mark.django_db +class TestEventStreamSettingUpdate: + def test_partial_update_allowed_ips( + self, + admin_client: APIClient, + default_event_stream_setting, + ): + pk = default_event_stream_setting.id + response = admin_client.patch( + f"{SETTINGS_URL}/{pk}/", + data={"allowed_ips": ["172.16.0.1"]}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.data["allowed_ips"] == ["172.16.0.1"] + + def test_adding_to_allowlist_removes_from_blocked( + self, + admin_client: APIClient, + default_event_stream_setting, + ): + pk = default_event_stream_setting.id + assert "192.168.1.100" in (default_event_stream_setting.blocked_ips) + response = admin_client.patch( + f"{SETTINGS_URL}/{pk}/", + data={ + "allowed_ips": [ + "10.0.0.1", + "10.0.0.2", + "192.168.1.100", + ], + }, + ) + assert response.status_code == status.HTTP_200_OK + assert "192.168.1.100" in response.data["allowed_ips"] + assert "192.168.1.100" not in response.data["blocked_ips"] + + +@pytest.mark.django_db +class TestEventStreamSettingValidation: + @pytest.mark.parametrize( + "bad_entry", + [ + "not-an-ip", + "999.999.999.999", + "192.168.1", + "192.168.1.1.1", + "", + "abc::xyz", + ], + ) + def test_malformed_ip_rejected( + self, + admin_client: APIClient, + default_organization, + bad_entry, + ): + response = admin_client.post( + f"{SETTINGS_URL}/", + data={ + "organization_id": default_organization.id, + "allowed_ips": [bad_entry], + }, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @pytest.mark.parametrize( + "bad_cidr", + [ + "192.168.1.0/abc", + "192.168.1.0/33", + "192.168.1.0/-1", + "not-a-network/24", + "/24", + "192.168.1.0/", + ], + ) + def test_malformed_cidr_rejected( + self, + admin_client: APIClient, + default_organization, + bad_cidr, + ): + response = admin_client.post( + f"{SETTINGS_URL}/", + data={ + "organization_id": default_organization.id, + "allowed_ips": [bad_cidr], + }, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_max_255_ips_enforced( + self, + admin_client: APIClient, + default_organization, + ): + ips = [f"10.0.{i // 256}.{i % 256}" for i in range(256)] + response = admin_client.post( + f"{SETTINGS_URL}/", + data={ + "organization_id": default_organization.id, + "allowed_ips": ips, + }, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_ipv6_accepted( + self, + admin_client: APIClient, + default_organization, + ): + response = admin_client.post( + f"{SETTINGS_URL}/", + data={ + "organization_id": default_organization.id, + "allowed_ips": ["::1", "2001:db8::1"], + }, + ) + assert response.status_code == status.HTTP_201_CREATED + + def test_cidr_accepted( + self, + admin_client: APIClient, + default_organization, + ): + response = admin_client.post( + f"{SETTINGS_URL}/", + data={ + "organization_id": default_organization.id, + "allowed_ips": [ + "192.30.252.0/22", + "10.0.0.1", + "2a0a:a440::/29", + ], + }, + ) + assert response.status_code == status.HTTP_201_CREATED + assert "192.30.252.0/22" in response.data["allowed_ips"] + + +@pytest.mark.django_db +class TestRemoveBlocked: + def test_remove_specific_ips( + self, + admin_client: APIClient, + default_event_stream_setting, + ): + pk = default_event_stream_setting.id + response = admin_client.post( + f"{SETTINGS_URL}/{pk}/remove-blocked/", + data={"ips": ["192.168.1.100"]}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.data["blocked_ips"] == [] + + def test_clear_all_blocked( + self, + admin_client: APIClient, + default_event_stream_setting, + ): + pk = default_event_stream_setting.id + response = admin_client.post( + f"{SETTINGS_URL}/{pk}/clear-blocked/", + ) + assert response.status_code == status.HTTP_200_OK + assert response.data["blocked_ips"] == [] + default_event_stream_setting.refresh_from_db() + assert default_event_stream_setting.blocked_ips == [] + + +def _create_hmac_event_stream(admin_client, org): + """Create an HMAC-authenticated event stream for IP policy tests.""" + secret = secrets.token_hex(32) + header_key = "X-Hub-Signature" + cred = create_event_stream_credential( + admin_client, + "HMAC Event Stream", + { + "auth_type": "hmac", + "secret": secret, + "http_header_key": header_key, + "hash_algorithm": "sha256", + "signature_encoding": "hex", + }, + name="ip-policy-test-cred", + ) + es = create_event_stream( + admin_client, + { + "name": "ip-policy-test-es", + "event_stream_type": cred["credential_type"]["kind"], + "eda_credential_id": cred["id"], + "organization_id": org.id, + }, + ) + return es, secret, header_key + + +@pytest.mark.django_db +class TestIpPolicyOnPost: + def test_allowlist_rejects_unlisted_ip( + self, + admin_client: APIClient, + preseed_credential_types, + ): + org = get_default_test_org() + es, secret, header_key = _create_hmac_event_stream(admin_client, org) + models.EventStreamSetting.objects.create( + organization=org, + allowed_ips=["192.168.1.1"], + ) + data = {"test": "payload"} + data_bytes = JSONRenderer().render(data) + sig = hmac.new( + secret.encode(), msg=data_bytes, digestmod=hashlib.sha256 + ).hexdigest() + response = admin_client.post( + event_stream_post_url(es.uuid), + headers={header_key: sig}, + data=data, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_no_settings_allows_request( + self, + admin_client: APIClient, + preseed_credential_types, + ): + org = get_default_test_org() + es, secret, header_key = _create_hmac_event_stream(admin_client, org) + data = {"test": "payload"} + data_bytes = JSONRenderer().render(data) + sig = hmac.new( + secret.encode(), msg=data_bytes, digestmod=hashlib.sha256 + ).hexdigest() + response = admin_client.post( + event_stream_post_url(es.uuid), + headers={header_key: sig}, + data=data, + ) + assert response.status_code == status.HTTP_200_OK + + def test_rejection_records_blocked_ip( + self, + admin_client: APIClient, + preseed_credential_types, + ): + org = get_default_test_org() + es, _, _ = _create_hmac_event_stream(admin_client, org) + setting = models.EventStreamSetting.objects.create( + organization=org, + allowed_ips=["192.168.1.1"], + ) + admin_client.post( + event_stream_post_url(es.uuid), + data={"test": "payload"}, + ) + setting.refresh_from_db() + assert len(setting.blocked_ips) > 0 diff --git a/tests/integration/api/test_root.py b/tests/integration/api/test_root.py index 93b0628b6..ff074bf2c 100644 --- a/tests/integration/api/test_root.py +++ b/tests/integration/api/test_root.py @@ -45,6 +45,7 @@ "/organizations/", "/teams/", "/event-streams/", + "/event-stream-settings/", "/credential-input-sources/", "/role_definitions/", "/role_user_assignments/", @@ -97,6 +98,7 @@ "/organizations/", "/teams/", "/event-streams/", + "/event-stream-settings/", "feature_flags/states/", # To be removed after all components # have migrated away from this endpoint diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 2f703b284..886438e64 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1199,6 +1199,15 @@ def default_event_stream( ) +@pytest.fixture +def default_event_stream_setting( + default_organization: models.Organization, +) -> models.EventStreamSetting: + return models.EventStreamSetting.objects.create( + organization=default_organization, + ) + + @pytest.fixture def activation_payload_skip_audit_events(activation_payload: dict) -> dict: activation_payload["skip_audit_events"] = True diff --git a/tests/integration/dab_rbac/conftest.py b/tests/integration/dab_rbac/conftest.py index fdb520ad2..aed85ac9d 100644 --- a/tests/integration/dab_rbac/conftest.py +++ b/tests/integration/dab_rbac/conftest.py @@ -82,9 +82,9 @@ def get_post_data(self, model_obj): post_data["eda_credentials"] = [obj["id"] for obj in related_objs] # handle EventStream uuid field - let serializer generate new UUID elif model_obj._meta.model == models.EventStream: - # Remove uuid from post_data to avoid uniqueness conflicts - # The serializer will generate a new UUID automatically post_data.pop("uuid", None) + elif model_obj._meta.model == models.EventStreamSetting: + model_obj.delete() return post_data diff --git a/tests/integration/dab_rbac/test_organization.py b/tests/integration/dab_rbac/test_organization.py index 70d118273..bf9268223 100644 --- a/tests/integration/dab_rbac/test_organization.py +++ b/tests/integration/dab_rbac/test_organization.py @@ -54,6 +54,8 @@ def test_create_with_default_org( if model._meta.model_name == "team": pytest.skip("Team model requires an organization") + if model._meta.model_name == "eventstreamsetting": + pytest.skip("Singleton per-org model tested separately") try: url = reverse(f"{model._meta.model_name}-list") @@ -116,6 +118,9 @@ def test_create_with_custom_org( # factory returns data with default org so we have to change it here post_data["organization_id"] = new_organization.id + if model._meta.model_name == "eventstreamsetting": + pytest.skip("Singleton per-org model tested separately") + try: url = reverse(f"{model._meta.model_name}-list") except NoReverseMatch: diff --git a/tests/unit/test_blacklist.py b/tests/unit/test_blacklist.py new file mode 100644 index 000000000..4d6b0f488 --- /dev/null +++ b/tests/unit/test_blacklist.py @@ -0,0 +1,110 @@ +# Copyright 2026 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest +from django.core.cache import cache +from rest_framework.exceptions import AuthenticationFailed + +from aap_eda.api.blacklist import BlacklistManager +from aap_eda.core import models + + +@pytest.fixture(autouse=True) +def clear_cache(): + cache.clear() + yield + cache.clear() + + +@pytest.fixture +def manager(): + return BlacklistManager() + + +@pytest.mark.django_db +class TestCheckIpPolicy: + def test_no_settings_allows_all(self, manager, default_organization): + manager.check_ip_policy("10.0.0.1", default_organization.id) + + def test_empty_allowlist_allows_all(self, manager, default_organization): + models.EventStreamSetting.objects.create( + organization=default_organization, + allowed_ips=[], + ) + manager.check_ip_policy("10.0.0.1", default_organization.id) + + def test_allowlist_rejects_unlisted_ip( + self, manager, default_organization + ): + models.EventStreamSetting.objects.create( + organization=default_organization, + allowed_ips=["10.0.0.1"], + ) + with pytest.raises(AuthenticationFailed, match="allowlist"): + manager.check_ip_policy("10.0.0.99", default_organization.id) + + def test_allowlist_passes_listed_ip(self, manager, default_organization): + models.EventStreamSetting.objects.create( + organization=default_organization, + allowed_ips=["10.0.0.1"], + ) + manager.check_ip_policy("10.0.0.1", default_organization.id) + + def test_cidr_allows_ip_in_range(self, manager, default_organization): + models.EventStreamSetting.objects.create( + organization=default_organization, + allowed_ips=["192.30.252.0/22"], + ) + manager.check_ip_policy("192.30.253.5", default_organization.id) + + def test_cidr_rejects_ip_outside_range( + self, manager, default_organization + ): + models.EventStreamSetting.objects.create( + organization=default_organization, + allowed_ips=["192.30.252.0/22"], + ) + with pytest.raises(AuthenticationFailed, match="allowlist"): + manager.check_ip_policy("10.0.0.1", default_organization.id) + + def test_mixed_ips_and_cidrs(self, manager, default_organization): + models.EventStreamSetting.objects.create( + organization=default_organization, + allowed_ips=["10.0.0.1", "192.30.252.0/22"], + ) + manager.check_ip_policy("10.0.0.1", default_organization.id) + manager.check_ip_policy("192.30.255.100", default_organization.id) + + +@pytest.mark.django_db +class TestRecordBlockedIp: + def test_records_rejected_ip(self, manager, default_organization): + setting = models.EventStreamSetting.objects.create( + organization=default_organization, + ) + manager.record_blocked_ip("10.0.0.1", default_organization.id) + setting.refresh_from_db() + assert "10.0.0.1" in setting.blocked_ips + + def test_does_not_duplicate(self, manager, default_organization): + setting = models.EventStreamSetting.objects.create( + organization=default_organization, + blocked_ips=["10.0.0.1"], + ) + manager.record_blocked_ip("10.0.0.1", default_organization.id) + setting.refresh_from_db() + assert setting.blocked_ips.count("10.0.0.1") == 1 + + def test_no_settings_row_is_noop(self, manager, default_organization): + manager.record_blocked_ip("10.0.0.1", default_organization.id) diff --git a/tests/unit/test_blacklist_org_aware.py b/tests/unit/test_blacklist_org_aware.py new file mode 100644 index 000000000..073d86d32 --- /dev/null +++ b/tests/unit/test_blacklist_org_aware.py @@ -0,0 +1,80 @@ +# Copyright 2026 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest +from django.core.cache import cache +from rest_framework.exceptions import AuthenticationFailed + +from aap_eda.api.blacklist import BlacklistManager +from aap_eda.core import models + + +@pytest.fixture(autouse=True) +def clear_cache(): + cache.clear() + yield + cache.clear() + + +@pytest.fixture +def manager(): + return BlacklistManager() + + +@pytest.mark.django_db +class TestOrgIsolation: + def test_separate_allowlists(self, manager): + org_a = models.Organization.objects.create(name="A") + org_b = models.Organization.objects.create(name="B") + models.EventStreamSetting.objects.create( + organization=org_a, + allowed_ips=["10.0.0.1"], + ) + models.EventStreamSetting.objects.create( + organization=org_b, + allowed_ips=["10.0.0.2"], + ) + manager.check_ip_policy("10.0.0.1", org_a.id) + with pytest.raises(AuthenticationFailed): + manager.check_ip_policy("10.0.0.1", org_b.id) + + def test_blocked_ips_per_org(self, manager): + org_a = models.Organization.objects.create(name="A") + org_b = models.Organization.objects.create(name="B") + setting_a = models.EventStreamSetting.objects.create( + organization=org_a, + ) + models.EventStreamSetting.objects.create( + organization=org_b, + ) + manager.record_blocked_ip("10.0.0.1", org_a.id) + setting_a.refresh_from_db() + setting_b = models.EventStreamSetting.objects.get(organization=org_b) + assert "10.0.0.1" in setting_a.blocked_ips + assert "10.0.0.1" not in setting_b.blocked_ips + + +@pytest.mark.django_db +class TestAllowlistRejectsAndRecords: + def test_rejection_auto_records_blocked_ip( + self, manager, default_organization + ): + setting = models.EventStreamSetting.objects.create( + organization=default_organization, + allowed_ips=["10.0.0.1"], + ) + with pytest.raises(AuthenticationFailed): + manager.check_ip_policy("10.0.0.99", default_organization.id) + setting.refresh_from_db() + assert "10.0.0.99" in setting.blocked_ips diff --git a/tests/unit/test_event_stream_setting_model.py b/tests/unit/test_event_stream_setting_model.py new file mode 100644 index 000000000..bbaf13ee0 --- /dev/null +++ b/tests/unit/test_event_stream_setting_model.py @@ -0,0 +1,88 @@ +# Copyright 2026 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest +from django.core.cache import cache + +from aap_eda.core import models +from aap_eda.services.event_stream_settings_cache import ( + get_org_settings, + invalidate_org_settings, +) + + +@pytest.fixture(autouse=True) +def clear_cache(): + cache.clear() + yield + cache.clear() + + +@pytest.mark.django_db +class TestGetOrgSettings: + def test_returns_db_values(self, default_organization): + models.EventStreamSetting.objects.create( + organization=default_organization, + allowed_ips=["10.0.0.1", "10.0.0.2"], + blocked_ips=["192.168.1.100"], + ) + result = get_org_settings(default_organization.id) + assert result["allowed_ips"] == { + "10.0.0.1", + "10.0.0.2", + } + assert result["blocked_ips"] == {"192.168.1.100"} + + def test_caches_result(self, default_organization): + models.EventStreamSetting.objects.create( + organization=default_organization, + allowed_ips=["10.0.0.1"], + ) + result1 = get_org_settings(default_organization.id) + models.EventStreamSetting.objects.filter( + organization=default_organization + ).update(allowed_ips=["10.0.0.99"]) + result2 = get_org_settings(default_organization.id) + assert result1["allowed_ips"] == result2["allowed_ips"] + + def test_no_row_returns_none(self, default_organization): + result = get_org_settings(default_organization.id) + assert result is None + + def test_invalidate_clears_cache(self, default_organization): + models.EventStreamSetting.objects.create( + organization=default_organization, + allowed_ips=["10.0.0.1"], + ) + get_org_settings(default_organization.id) + invalidate_org_settings(default_organization.id) + models.EventStreamSetting.objects.filter( + organization=default_organization + ).update(allowed_ips=["10.0.0.99"]) + result = get_org_settings(default_organization.id) + assert result["allowed_ips"] == {"10.0.0.99"} + + +@pytest.mark.django_db +class TestSignalInvalidation: + def test_post_save_invalidates_cache(self, default_organization): + setting = models.EventStreamSetting.objects.create( + organization=default_organization, + allowed_ips=["10.0.0.1"], + ) + get_org_settings(default_organization.id) + setting.allowed_ips = ["10.0.0.99"] + setting.save() + result = get_org_settings(default_organization.id) + assert result["allowed_ips"] == {"10.0.0.99"} diff --git a/tests/unit/test_timing_safe_compare.py b/tests/unit/test_timing_safe_compare.py new file mode 100644 index 000000000..0779ebc40 --- /dev/null +++ b/tests/unit/test_timing_safe_compare.py @@ -0,0 +1,39 @@ +# Copyright 2026 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from aap_eda.core.utils.crypto import timing_safe_compare + + +def test_equal_strings(): + assert timing_safe_compare("abc", "abc") is True + + +def test_unequal_strings(): + assert timing_safe_compare("abc", "xyz") is False + + +def test_empty_strings(): + assert timing_safe_compare("", "") is True + + +def test_empty_vs_nonempty(): + assert timing_safe_compare("", "a") is False + + +def test_non_ascii_equal(): + assert timing_safe_compare("café", "café") is True + + +def test_non_ascii_unequal(): + assert timing_safe_compare("café", "naïve") is False