From 5d9caeb89c44241114b1f07544f4ea67d289060f Mon Sep 17 00:00:00 2001 From: Jitka Halova Date: Thu, 9 Apr 2026 19:13:56 +0200 Subject: [PATCH] Add repository-specific package blocklist closes #1166 Assisted By: Claude Opus 4.6 --- CHANGES/1166.feature | 1 + docs/user/guides/upload.md | 2 + .../migrations/0022_pythonblocklistentry.py | 48 ++++++ pulp_python/app/models.py | 59 +++++++ pulp_python/app/serializers.py | 126 ++++++++++++++- pulp_python/app/viewsets.py | 81 ++++++++++ .../tests/functional/api/test_blocklist.py | 148 ++++++++++++++++++ 7 files changed, 464 insertions(+), 1 deletion(-) create mode 100644 CHANGES/1166.feature create mode 100644 pulp_python/app/migrations/0022_pythonblocklistentry.py create mode 100644 pulp_python/tests/functional/api/test_blocklist.py diff --git a/CHANGES/1166.feature b/CHANGES/1166.feature new file mode 100644 index 00000000..5909a2f3 --- /dev/null +++ b/CHANGES/1166.feature @@ -0,0 +1 @@ +Added repository-specific package blocklist. diff --git a/docs/user/guides/upload.md b/docs/user/guides/upload.md index c4340242..41e0a9ab 100644 --- a/docs/user/guides/upload.md +++ b/docs/user/guides/upload.md @@ -135,6 +135,8 @@ as Pulp may contain different content units with the same name. } ``` +TODO: blocklist docs + ## Remove content from a repository A content unit can be removed from a repository using the `remove` command. diff --git a/pulp_python/app/migrations/0022_pythonblocklistentry.py b/pulp_python/app/migrations/0022_pythonblocklistentry.py new file mode 100644 index 00000000..9e3e61cb --- /dev/null +++ b/pulp_python/app/migrations/0022_pythonblocklistentry.py @@ -0,0 +1,48 @@ +# Generated by Django 5.2.10 on 2026-04-09 08:27 + +import django.db.models.deletion +import django_lifecycle.mixins +import pulpcore.app.models.base +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("python", "0021_pythonrepository_upload_duplicate_filenames"), + ] + + operations = [ + migrations.CreateModel( + name="PythonBlocklistEntry", + fields=[ + ( + "pulp_id", + models.UUIDField( + default=pulpcore.app.models.base.pulp_uuid, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("pulp_created", models.DateTimeField(auto_now_add=True)), + ("pulp_last_updated", models.DateTimeField(auto_now=True, null=True)), + ("name", models.TextField(default=None, null=True)), + ("version", models.TextField(default=None, null=True)), + ("filename", models.TextField(default=None, null=True)), + ("added_by", models.TextField(blank=True, default="")), + ( + "repository", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="blocklist_entries", + to="python.pythonrepository", + ), + ), + ], + options={ + "default_related_name": "%(app_label)s_%(model_name)s", + }, + bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model), + ), + ] diff --git a/pulp_python/app/models.py b/pulp_python/app/models.py index eda9a7d1..dc5f5a97 100644 --- a/pulp_python/app/models.py +++ b/pulp_python/app/models.py @@ -14,6 +14,7 @@ from rest_framework.serializers import ValidationError from pulpcore.plugin.models import ( AutoAddObjPermsMixin, + BaseModel, Content, Publication, Distribution, @@ -399,9 +400,12 @@ def finalize_new_version(self, new_version): When allow_package_substitution is False, reject any new version that would implicitly replace existing content with different checksums (content substitution). + + Also checks newly added content against the repository's blocklist entries. """ if not self.allow_package_substitution: self._check_for_package_substitution(new_version) + self._check_blocklist(new_version) remove_duplicates(new_version) validate_repo_version(new_version) @@ -418,3 +422,58 @@ def _check_for_package_substitution(self, new_version): "To allow this, set 'allow_package_substitution' to True on the repository. " f"Conflicting packages: {duplicates}" ) + + def _check_blocklist(self, new_version): + """ + Check newly added content in a repository version against the blocklist. + """ + added_content = PythonPackageContent.objects.filter( + pk__in=new_version.added().values_list("pk", flat=True) + ) + if added_content.exists(): + self.check_blocklist_for_packages(added_content) + + def check_blocklist_for_packages(self, packages): + """ + Raise a ValidationError if any of the given packages match a blocklist entry. + """ + entries = PythonBlocklistEntry.objects.filter(repository=self) + if not entries.exists(): + return + + blocked = [] + for pkg in packages: + pkg_name_normalized = canonicalize_name(pkg.name) if pkg.name else "" + for entry in entries: + if entry.filename and entry.filename == pkg.filename: + blocked.append(pkg.filename) + break + if entry.name and canonicalize_name(entry.name) == pkg_name_normalized: + if not entry.version or entry.version == pkg.version: + blocked.append(pkg.filename) + break + if blocked: + raise ValidationError( + "Blocklisted packages cannot be added to this repository: " + "{}".format(", ".join(blocked)) + ) + + +class PythonBlocklistEntry(BaseModel): + """ + An entry in a PythonRepository's package blocklist. + + Blocks package uploads by exact filename, package name, or package name + version. + At least one of `name` or `filename` must be non-empty. + """ + + name = models.TextField(null=True, default=None) + version = models.TextField(null=True, default=None) + filename = models.TextField(null=True, default=None) + added_by = models.TextField(blank=True, default="") + repository = models.ForeignKey( + PythonRepository, on_delete=models.CASCADE, related_name="blocklist_entries" + ) + + class Meta: + default_related_name = "%(app_label)s_%(model_name)s" diff --git a/pulp_python/app/serializers.py b/pulp_python/app/serializers.py index dc4355f9..7de50a38 100644 --- a/pulp_python/app/serializers.py +++ b/pulp_python/app/serializers.py @@ -6,14 +6,17 @@ from django.db.utils import IntegrityError from drf_spectacular.utils import extend_schema_serializer from packaging.requirements import Requirement +from packaging.version import Version, InvalidVersion from rest_framework import serializers +from rest_framework_nested.relations import NestedHyperlinkedIdentityField +from rest_framework_nested.serializers import NestedHyperlinkedModelSerializer from pypi_attestations import AttestationError from pydantic import TypeAdapter, ValidationError from urllib.parse import urljoin from pulpcore.plugin import models as core_models from pulpcore.plugin import serializers as core_serializers -from pulpcore.plugin.util import get_domain, get_prn, get_current_authenticated_user +from pulpcore.plugin.util import get_domain, get_prn, get_current_authenticated_user, reverse from pulp_python.app import models as python_models from pulp_python.app.provenance import ( @@ -53,6 +56,11 @@ class PythonRepositorySerializer(core_serializers.RepositorySerializer): default=False, required=False, ) + blocklist_entries_href = serializers.SerializerMethodField( + help_text=_("URL to the blocklist entries for this repository."), + read_only=True, + ) + allow_package_substitution = serializers.BooleanField( help_text=_( "Whether to allow package substitution (replacing existing packages with packages " @@ -65,10 +73,15 @@ class PythonRepositorySerializer(core_serializers.RepositorySerializer): required=False, ) + def get_blocklist_entries_href(self, obj): + repo_href = reverse("repositories-python/python-detail", kwargs={"pk": obj.pk}) + return f"{repo_href}blocklist_entries/" + class Meta: fields = core_serializers.RepositorySerializer.Meta.fields + ( "autopublish", "allow_package_substitution", + "blocklist_entries_href", ) model = python_models.PythonRepository @@ -780,6 +793,117 @@ class Meta: model = python_models.PythonRemote +class _NestedIdentityField(NestedHyperlinkedIdentityField): + """ + NestedHyperlinkedIdentityField that uses pulpcore's reverse for relative URLs. + Mimics NestedIdentityField from pulpcore, which is not exposed via the plugin API. + """ + + def get_url(self, obj, view_name, request, *args, **kwargs): + self.reverse = reverse + return super().get_url(obj, view_name, request, *args, **kwargs) + + +class PythonBlocklistEntrySerializer( + core_serializers.ModelSerializer, NestedHyperlinkedModelSerializer +): + """ + Serializer for PythonBlocklistEntry. + + The `repository` is supplied by the URL (not the request body) and is injected + by the viewset before saving. + """ + + pulp_href = _NestedIdentityField( + view_name="blocklist_entries-detail", + parent_lookup_kwargs={"repository_pk": "repository__pk"}, + ) + repository = core_serializers.DetailRelatedField( + read_only=True, + view_name_pattern=r"repositories(-.*/.*)?-detail", + help_text=_("Repository this blocklist entry belongs to."), + ) + name = serializers.CharField( + required=False, + allow_null=True, + default=None, + help_text=_( + "Package name to block (for all versions). Compared after PEP 503 normalization. " + "Required when 'filename' is not provided." + ), + ) + version = serializers.CharField( + required=False, + allow_null=True, + default=None, + help_text=_("Exact version string to block (e.g. '1.0'). Only used when 'name' is set."), + ) + filename = serializers.CharField( + required=False, + allow_null=True, + default=None, + help_text=_("Exact filename to block. Required when 'name' is not provided."), + ) + added_by = serializers.CharField(read_only=True) + + def validate(self, data): + """ + Validate that the blocklist entry is well-formed and not a duplicate. + """ + name = data.get("name") + filename = data.get("filename") + version = data.get("version") + + if version and filename: + raise serializers.ValidationError(_("'version' cannot be used with 'filename'.")) + if version and not name: + raise serializers.ValidationError(_("'version' requires 'name' to be provided.")) + if name and filename: + raise serializers.ValidationError(_("'name' and 'filename' are mutually exclusive.")) + if not name and not filename: + raise serializers.ValidationError(_("Either 'name' or 'filename' must be provided.")) + + if version: + try: + Version(version) + except InvalidVersion: + raise serializers.ValidationError( + {"version": _("'{}' is not a valid version.").format(version)} + ) + + repository = self.context.get("repository") + if repository: + qs = python_models.PythonBlocklistEntry.objects.filter(repository=repository) + if name and qs.filter(name=name, version=version).exists(): + raise serializers.ValidationError( + _("A blocklist entry with this name and version already exists.") + ) + if filename and qs.filter(filename=filename).exists(): + raise serializers.ValidationError( + _("A blocklist entry with this filename already exists.") + ) + + return data + + def create(self, validated_data): + """ + Create a new blocklist entry, recording the authenticated user in `added_by`. + """ + user = get_current_authenticated_user() + validated_data["added_by"] = get_prn(user) if user else "" + return super().create(validated_data) + + class Meta: + fields = core_serializers.ModelSerializer.Meta.fields + ( + "repository", + "name", + "version", + "filename", + "added_by", + ) + model = python_models.PythonBlocklistEntry + + class PythonBanderRemoteSerializer(serializers.Serializer): """ A Serializer for the initial step of creating a Python Remote from a Bandersnatch config file diff --git a/pulp_python/app/viewsets.py b/pulp_python/app/viewsets.py index 6ff26dc3..5d4684c6 100644 --- a/pulp_python/app/viewsets.py +++ b/pulp_python/app/viewsets.py @@ -7,9 +7,16 @@ from pathlib import Path from rest_framework import status from rest_framework.decorators import action +from rest_framework.mixins import ( + CreateModelMixin, + DestroyModelMixin, + ListModelMixin, + RetrieveModelMixin, +) from rest_framework.response import Response from rest_framework.serializers import ValidationError + from pulpcore.plugin import viewsets as core_viewsets from pulpcore.plugin.actions import ModifyRepositoryActionMixin from pulpcore.plugin.models import RepositoryVersion @@ -143,8 +150,12 @@ def modify(self, request, pk): If allow_package_substitution is False and the request is **only** adding packages, then a package substitution check is performed to provide a quicker error response. Otherwise, the check is delegated to the task. + + Also performs an early blocklist check on added packages. """ repository = self.get_object() + self._early_blocklist_check(repository, request) + if not repository.allow_package_substitution: remove_content_units = request.data.get("remove_content_units", []) if remove_content_units or "base_version" in request.data: @@ -167,6 +178,17 @@ def modify(self, request, pk): ) return super().modify(request, pk) + def _early_blocklist_check(self, repository, request): + """ + Raise early if any added packages match a blocklist entry. + """ + add_content_units = request.data.get("add_content_units", []) + if not add_content_units: + return None + content_ids = [extract_pk(x) for x in add_content_units] + packages = python_models.PythonPackageContent.objects.filter(pk__in=content_ids) + repository.check_blocklist_for_packages(packages) + @extend_schema( summary="Repair metadata", responses={202: AsyncOperationResponseSerializer}, @@ -216,6 +238,65 @@ def sync(self, request, pk): return core_viewsets.OperationPostponedResponse(result, request) +class PythonBlocklistEntryViewSet( + core_viewsets.NamedModelViewSet, + CreateModelMixin, + RetrieveModelMixin, + ListModelMixin, + DestroyModelMixin, +): + """ + ViewSet for managing blocklist entries on a PythonRepository. + + Blocklist entries prevent packages from being uploaded to the repository. + Each entry can match by exact filename, package name, or package name with an exact version. + """ + + endpoint_name = "blocklist_entries" + router_lookup = "pythonblocklistentry" + parent_viewset = PythonRepositoryViewSet + parent_lookup_kwargs = {"repository_pk": "repository__pk"} + serializer_class = python_serializers.PythonBlocklistEntrySerializer + queryset = python_models.PythonBlocklistEntry.objects.all() + ordering = ("-pulp_created",) + + DEFAULT_ACCESS_POLICY = { + "statements": [ + { + "action": ["list", "retrieve"], + "principal": "authenticated", + "effect": "allow", + "condition": "has_repository_model_or_domain_or_obj_perms:python.view_pythonrepository", # noqa: E501 + }, + { + "action": ["create", "destroy"], + "principal": "authenticated", + "effect": "allow", + "condition": [ + "has_repository_model_or_domain_or_obj_perms:python.modify_pythonrepository", + "has_repository_model_or_domain_or_obj_perms:python.view_pythonrepository", + ], + }, + ], + } + + def get_serializer_context(self): + """ + Inject the parent repository into the serializer context so that `validate()` can check for + duplicate entries. The guard on `repository_pk` prevents errors during schema generation. + """ + context = super().get_serializer_context() + if self.kwargs.get("repository_pk"): + context["repository"] = self.get_parent_object() + return context + + def perform_create(self, serializer): + """ + Set the repository FK from the URL before saving the entry. + """ + serializer.save(repository=self.get_parent_object()) + + class PythonRepositoryVersionViewSet(core_viewsets.RepositoryVersionViewSet): """ PythonRepositoryVersion represents a single Python repository version. diff --git a/pulp_python/tests/functional/api/test_blocklist.py b/pulp_python/tests/functional/api/test_blocklist.py new file mode 100644 index 00000000..b5d4f2fc --- /dev/null +++ b/pulp_python/tests/functional/api/test_blocklist.py @@ -0,0 +1,148 @@ +import pytest + +from pulpcore.tests.functional.utils import PulpTaskError +from pulp_python.tests.functional.constants import PYTHON_EGG_FILENAME, PYTHON_EGG_URL + +CONTENT_BODY = {"relative_path": PYTHON_EGG_FILENAME, "file_url": PYTHON_EGG_URL} +BLOCKED_MSG = "Blocklisted packages cannot be added to this repository" + + +@pytest.mark.parallel +def test_crd_entry(python_bindings, python_repo): + """ + CRUD operations on blocklist entries return correct fields and update the entry count. + """ + entries_data = [ + ({"name": "shelf-reader"}, "shelf-reader", None, None), + ({"name": "shelf-reader", "version": "0.1"}, "shelf-reader", "0.1", None), + ({"filename": PYTHON_EGG_FILENAME}, None, None, PYTHON_EGG_FILENAME), + ] + for body_kwargs, name, version, filename in entries_data: + entry = python_bindings.RepositoriesPythonBlocklistEntriesApi.create( + python_repo.pulp_href, python_bindings.PythonPythonBlocklistEntry(**body_kwargs) + ) + assert entry.name == name + assert entry.version == version + assert entry.filename == filename + assert entry.added_by == "prn:auth.user:1" + assert entry.pulp_href is not None + assert entry.prn is not None + + result = python_bindings.RepositoriesPythonBlocklistEntriesApi.list(python_repo.pulp_href) + assert result.count == 3 + + entry = result.results[0] + python_bindings.RepositoriesPythonBlocklistEntriesApi.read(entry.pulp_href) + + python_bindings.RepositoriesPythonBlocklistEntriesApi.delete(entry.pulp_href) + result = python_bindings.RepositoriesPythonBlocklistEntriesApi.list(python_repo.pulp_href) + assert result.count == 2 + + +@pytest.mark.parallel +@pytest.mark.parametrize( + "body_kwargs, expected_msg", + [ + ({"name": "shelf-reader"}, "this name and version already exists"), + ({"name": "shelf-reader", "version": "0.1"}, "this name and version already exists"), + ({"filename": PYTHON_EGG_FILENAME}, "this filename already exists"), + ], +) +def test_duplicate_entry_rejected(python_bindings, python_repo, body_kwargs, expected_msg): + """ + Creating a duplicate entry should fail. + """ + python_bindings.RepositoriesPythonBlocklistEntriesApi.create( + python_repo.pulp_href, python_bindings.PythonPythonBlocklistEntry(**body_kwargs) + ) + with pytest.raises(python_bindings.ApiException) as ctx: + python_bindings.RepositoriesPythonBlocklistEntriesApi.create( + python_repo.pulp_href, python_bindings.PythonPythonBlocklistEntry(**body_kwargs) + ) + assert ctx.value.status == 400 + assert expected_msg in ctx.value.body + + +@pytest.mark.parallel +@pytest.mark.parametrize( + "body_kwargs, expected_msg", + [ + ({"version": "0.1", "filename": PYTHON_EGG_FILENAME}, "version' cannot be used with"), + ({"version": "0.1"}, "version' requires 'name'"), + ({"name": "shelf-reader", "filename": PYTHON_EGG_FILENAME}, "mutually exclusive"), + ({}, "Either"), + ({"name": "shelf-reader", "version": "not-a-version"}, "not a valid version"), + ], +) +def test_invalid_entry_rejected(python_bindings, python_repo, body_kwargs, expected_msg): + """ + Creating an entry with invalid data should fail. + """ + with pytest.raises(python_bindings.ApiException) as ctx: + python_bindings.RepositoriesPythonBlocklistEntriesApi.create( + python_repo.pulp_href, python_bindings.PythonPythonBlocklistEntry(**body_kwargs) + ) + assert ctx.value.status == 400 + assert expected_msg in ctx.value.body + + +def test_upload_blocked(delete_orphans_pre, monitor_task, python_bindings, python_repo): + """ + Uploading a package matching a blocklist entry is rejected. + """ + python_bindings.RepositoriesPythonBlocklistEntriesApi.create( + python_repo.pulp_href, + python_bindings.PythonPythonBlocklistEntry(name="shelf-reader"), + ) + + with pytest.raises(PulpTaskError) as exc: + response = python_bindings.ContentPackagesApi.create( + repository=python_repo.pulp_href, **CONTENT_BODY + ) + monitor_task(response.task) + assert BLOCKED_MSG in exc.value.task.error["description"] + + repo = python_bindings.RepositoriesPythonApi.read(python_repo.pulp_href) + assert repo.latest_version_href.endswith("/0/") + + +def test_upload_allowed(delete_orphans_pre, monitor_task, python_bindings, python_repo): + """ + Uploading a package is allowed when the blocklist entry targets a different version. + """ + python_bindings.RepositoriesPythonBlocklistEntriesApi.create( + python_repo.pulp_href, + python_bindings.PythonPythonBlocklistEntry(name="shelf-reader", version="9.9"), + ) + + response = python_bindings.ContentPackagesApi.create( + repository=python_repo.pulp_href, **CONTENT_BODY + ) + monitor_task(response.task) + + repo = python_bindings.RepositoriesPythonApi.read(python_repo.pulp_href) + assert repo.latest_version_href.endswith("/1/") + + +def test_modify_blocked(delete_orphans_pre, monitor_task, python_bindings, python_repo): + """ + Adding a blocklisted package via repository modify is rejected. + """ + python_bindings.RepositoriesPythonBlocklistEntriesApi.create( + python_repo.pulp_href, + python_bindings.PythonPythonBlocklistEntry(name="shelf-reader"), + ) + + response = python_bindings.ContentPackagesApi.create(**CONTENT_BODY) + task = monitor_task(response.task) + content = python_bindings.ContentPackagesApi.read(task.created_resources[0]) + + with pytest.raises(python_bindings.ApiException) as exc: + python_bindings.RepositoriesPythonApi.modify( + python_repo.pulp_href, {"add_content_units": [content.pulp_href]} + ) + assert exc.value.status == 400 + assert BLOCKED_MSG in exc.value.body + + repo = python_bindings.RepositoriesPythonApi.read(python_repo.pulp_href) + assert repo.latest_version_href.endswith("/0/")