From de2a7d8c7d70b6dbc2fd4bcca6d6eccda7b9633d Mon Sep 17 00:00:00 2001 From: Jitka Halova Date: Thu, 16 Apr 2026 12:50:37 +0200 Subject: [PATCH] Add repository-specific package blocklist closes #1166 Assisted By: Claude Opus 4.6 --- CHANGES/1166.feature | 1 + docs/user/guides/_SUMMARY.md | 1 + docs/user/guides/blocklist.md | 73 +++++++++ .../migrations/0022_pythonblocklistentry.py | 48 ++++++ pulp_python/app/models.py | 64 ++++++++ pulp_python/app/serializers.py | 123 +++++++++++++- pulp_python/app/viewsets.py | 80 +++++++++- .../tests/functional/api/test_blocklist.py | 151 ++++++++++++++++++ 8 files changed, 538 insertions(+), 3 deletions(-) create mode 100644 CHANGES/1166.feature create mode 100644 docs/user/guides/blocklist.md 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/_SUMMARY.md b/docs/user/guides/_SUMMARY.md index 920b5be2..b92c4aca 100644 --- a/docs/user/guides/_SUMMARY.md +++ b/docs/user/guides/_SUMMARY.md @@ -4,3 +4,4 @@ * [Host Python Content](host.md) * [Vulnerability Report](vulnerability_report.md) * [Attestation Hosting](attestation.md) +* [Package Blocklist](blocklist.md) diff --git a/docs/user/guides/blocklist.md b/docs/user/guides/blocklist.md new file mode 100644 index 00000000..81feaa3b --- /dev/null +++ b/docs/user/guides/blocklist.md @@ -0,0 +1,73 @@ +# Package Blocklist + +A repository can have a blocklist that prevents specific packages from being added. +Blocklist entries can match by package `name` (all versions), package `name` with an exact `version`, or exact `filename`. +Exactly one of `name` or `filename` must be provided. + +Each entry records the PRN of the user who created it in the `added_by` field. + +## Setup + +If you do not already have a repository, create one: + +```bash +pulp python repository create --name foo +``` + +Set the API base URL and repository HREF for use in the subsequent commands: + +```bash +PULP_API="http://localhost:5001" +REPO_HREF=$(pulp python repository show --name foo | jq -r ".pulp_href") +``` + +## Add a blocklist entry + +=== "By name (all versions)" + + ```bash + # Block all versions of shelf-reader + http POST "${PULP_API}${REPO_HREF}blocklist_entries/" name="shelf-reader" + ``` + +=== "By name and version" + + ```bash + # Block only shelf-reader 0.1 + http POST "${PULP_API}${REPO_HREF}blocklist_entries/" name="shelf-reader" version="0.1" + ``` + +=== "By filename" + + ```bash + # Block only shelf-reader-0.1.tar.gz + http POST "${PULP_API}${REPO_HREF}blocklist_entries/" filename="shelf-reader-0.1.tar.gz" + ``` + +Set the UUID of a created entry for use in the subsequent commands: + +```bash +ENTRY_UUID=$(http GET "${PULP_API}${REPO_HREF}blocklist_entries/" | jq -r '.results[0].prn | split(":") | .[-1]') +``` + +## List blocklist entries + +List all entries for a repository: + +```bash +http GET "${PULP_API}${REPO_HREF}blocklist_entries/" +``` + +Show a single entry: + +```bash +http GET "${PULP_API}${REPO_HREF}blocklist_entries/${ENTRY_UUID}/" +``` + +## Remove a blocklist entry + +```bash +http DELETE "${PULP_API}${REPO_HREF}blocklist_entries/${ENTRY_UUID}/" +``` + +Once an entry is removed, packages matching it can be added to the repository again. diff --git a/pulp_python/app/migrations/0022_pythonblocklistentry.py b/pulp_python/app/migrations/0022_pythonblocklistentry.py new file mode 100644 index 00000000..49469d6e --- /dev/null +++ b/pulp_python/app/migrations/0022_pythonblocklistentry.py @@ -0,0 +1,48 @@ +# Generated by Django 5.2.10 on 2026-04-16 14:00 + +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(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..fefa655b 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,63 @@ 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) + ).only("filename", "name_normalized", "version") + 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: + for entry in entries: + if entry.filename and entry.filename == pkg.filename: + blocked.append(pkg.filename) + break + if 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. + + Blocklist entries prevent packages from being added to the repository. + Entries can match by package `name` (all versions), package `name` + `version`, + or exact `filename`. Exactly one of `name` or `filename` must be provided. + """ + + 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(default="") + repository = models.ForeignKey( + PythonRepository, on_delete=models.CASCADE, related_name="blocklist_entries" + ) + + def __str__(self): + if self.filename: + return f"<{self._meta.object_name}: {self.filename}>" + return f"<{self._meta.object_name}: {self.name} [{self.version or 'all'}]>" + + 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..35fd5ca5 100644 --- a/pulp_python/app/serializers.py +++ b/pulp_python/app/serializers.py @@ -6,6 +6,7 @@ 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 pypi_attestations import AttestationError from pydantic import TypeAdapter, ValidationError @@ -13,9 +14,10 @@ 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.utils import canonicalize_name from pulp_python.app.provenance import ( Attestation, Provenance, @@ -53,6 +55,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 +72,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 +792,115 @@ class Meta: model = python_models.PythonRemote +class PythonBlocklistEntrySerializer(core_serializers.ModelSerializer): + """ + Serializer for PythonBlocklistEntry. + + The `repository` is supplied by the URL (not the request body) and is injected + by the viewset before saving. + """ + + pulp_href = serializers.SerializerMethodField( + read_only=True, + help_text=_("The URL of this blocklist entry."), + ) + 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, + help_text=_("PRN of the user who added this blocklist entry."), + ) + + def get_pulp_href(self, obj): + repo_href = reverse("repositories-python/python-detail", kwargs={"pk": obj.repository_id}) + return f"{repo_href}blocklist_entries/{obj.pk}/" + + 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 bool(name) == bool(filename): + raise serializers.ValidationError( + _("Exactly one of 'name' or 'filename' must be provided.") + ) + + if version: + try: + Version(version) + except InvalidVersion: + raise serializers.ValidationError( + {"version": _("'{}' is not a valid version.").format(version)} + ) + if name: + data["name"] = canonicalize_name(name) + name = data["name"] + + 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.") + ) + data["repository"] = repository + + 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..83109b36 100644 --- a/pulp_python/app/viewsets.py +++ b/pulp_python/app/viewsets.py @@ -7,6 +7,12 @@ 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 @@ -143,15 +149,20 @@ 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() + add_content_units = request.data.get("add_content_units", []) + content_ids = [extract_pk(x) for x in add_content_units] + + self._early_blocklist_check(repository, content_ids) + 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: return super().modify(request, pk) rvc = repository.latest_version().content - add_content_units = request.data.get("add_content_units", []) - content_ids = [extract_pk(x) for x in add_content_units] packages = ( python_models.PythonPackageContent.objects.filter(pk__in=content_ids) .exclude(pk__in=rvc) @@ -167,6 +178,17 @@ def modify(self, request, pk): ) return super().modify(request, pk) + def _early_blocklist_check(self, repository, content_ids): + """ + Raise early if any added packages match a blocklist entry. + """ + if not content_ids: + return + packages = python_models.PythonPackageContent.objects.filter(pk__in=content_ids).only( + "filename", "name_normalized", "version" + ) + repository.check_blocklist_for_packages(packages) + @extend_schema( summary="Repair metadata", responses={202: AsyncOperationResponseSerializer}, @@ -216,6 +238,60 @@ 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 added to the repository. + Entries can match by package `name` (all versions), package `name` + `version`, + or exact `filename`. Exactly one of `name` or `filename` must be provided. + """ + + 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 + + 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..c6c62f85 --- /dev/null +++ b/pulp_python/tests/functional/api/test_blocklist.py @@ -0,0 +1,151 @@ +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): + """ + CRD 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}, "Exactly one of"), + ({}, "Exactly one of"), + ({"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 + + +@pytest.mark.parallel +def test_upload_blocked(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", version="0.1"), + ) + + 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/") + + +@pytest.mark.parallel +def test_upload_allowed(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/") + + +@pytest.mark.parallel +def test_modify_blocked(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", version="0.1"), + ) + + 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/")