Skip to content

Commit 22d41d5

Browse files
committed
Add repository-specific package blocklist
closes #1166 Assisted By: Claude Opus 4.6
1 parent e1e023c commit 22d41d5

7 files changed

Lines changed: 450 additions & 2 deletions

File tree

CHANGES/1166.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added repository-specific package blocklist.

docs/user/guides/upload.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ as Pulp may contain different content units with the same name.
135135
}
136136
```
137137

138+
TODO: blocklist docs
139+
138140
## Remove content from a repository
139141

140142
A content unit can be removed from a repository using the `remove` command.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Generated by Django 5.2.10 on 2026-04-09 08:27
2+
3+
import django.db.models.deletion
4+
import django_lifecycle.mixins
5+
import pulpcore.app.models.base
6+
from django.db import migrations, models
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
("python", "0021_pythonrepository_upload_duplicate_filenames"),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name="PythonBlocklistEntry",
18+
fields=[
19+
(
20+
"pulp_id",
21+
models.UUIDField(
22+
default=pulpcore.app.models.base.pulp_uuid,
23+
editable=False,
24+
primary_key=True,
25+
serialize=False,
26+
),
27+
),
28+
("pulp_created", models.DateTimeField(auto_now_add=True)),
29+
("pulp_last_updated", models.DateTimeField(auto_now=True, null=True)),
30+
("name", models.TextField(default=None, null=True)),
31+
("version", models.TextField(default=None, null=True)),
32+
("filename", models.TextField(default=None, null=True)),
33+
("added_by", models.TextField(blank=True, default="")),
34+
(
35+
"repository",
36+
models.ForeignKey(
37+
on_delete=django.db.models.deletion.CASCADE,
38+
related_name="blocklist_entries",
39+
to="python.pythonrepository",
40+
),
41+
),
42+
],
43+
options={
44+
"default_related_name": "%(app_label)s_%(model_name)s",
45+
},
46+
bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model),
47+
),
48+
]

pulp_python/app/models.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from rest_framework.serializers import ValidationError
1515
from pulpcore.plugin.models import (
1616
AutoAddObjPermsMixin,
17+
BaseModel,
1718
Content,
1819
Publication,
1920
Distribution,
@@ -399,9 +400,12 @@ def finalize_new_version(self, new_version):
399400
400401
When allow_package_substitution is False, reject any new version that would implicitly
401402
replace existing content with different checksums (content substitution).
403+
404+
Also checks newly added content against the repository's blocklist entries.
402405
"""
403406
if not self.allow_package_substitution:
404407
self._check_for_package_substitution(new_version)
408+
self._check_blocklist(new_version)
405409
remove_duplicates(new_version)
406410
validate_repo_version(new_version)
407411

@@ -418,3 +422,55 @@ def _check_for_package_substitution(self, new_version):
418422
"To allow this, set 'allow_package_substitution' to True on the repository. "
419423
f"Conflicting packages: {duplicates}"
420424
)
425+
426+
def _check_blocklist(self, new_version):
427+
"""
428+
Raise a ValidationError if any newly added package matches a blocklist entry.
429+
"""
430+
entries = PythonBlocklistEntry.objects.filter(repository=self)
431+
if not entries.exists():
432+
return
433+
434+
added_content = PythonPackageContent.objects.filter(
435+
pk__in=new_version.added().values_list("pk", flat=True)
436+
)
437+
if not added_content.exists():
438+
return
439+
440+
blocked = []
441+
for pkg in added_content:
442+
pkg_name_normalized = canonicalize_name(pkg.name) if pkg.name else ""
443+
for entry in entries:
444+
if entry.filename and entry.filename == pkg.filename:
445+
blocked.append(pkg.filename)
446+
break
447+
if entry.name and canonicalize_name(entry.name) == pkg_name_normalized:
448+
if not entry.version or entry.version == pkg.version:
449+
blocked.append(pkg.filename)
450+
break
451+
if blocked:
452+
raise ValidationError(
453+
"Blocklisted packages cannot be added to this repository: "
454+
"{}".format(", ".join(blocked))
455+
)
456+
457+
458+
class PythonBlocklistEntry(BaseModel):
459+
"""
460+
An entry in a PythonRepository's package blocklist.
461+
462+
Blocks package uploads by exact filename, package name, or package name + version.
463+
At least one of ``name`` or ``filename`` must be non-empty.
464+
"""
465+
466+
# todo? domain?, added_by?
467+
name = models.TextField(null=True, default=None)
468+
version = models.TextField(null=True, default=None)
469+
filename = models.TextField(null=True, default=None)
470+
added_by = models.TextField(blank=True, default="")
471+
repository = models.ForeignKey(
472+
PythonRepository, on_delete=models.CASCADE, related_name="blocklist_entries"
473+
)
474+
475+
class Meta:
476+
default_related_name = "%(app_label)s_%(model_name)s"

pulp_python/app/serializers.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,16 @@
66
from django.db.utils import IntegrityError
77
from drf_spectacular.utils import extend_schema_serializer
88
from packaging.requirements import Requirement
9+
from packaging.version import Version, InvalidVersion
910
from rest_framework import serializers
11+
from rest_framework_nested.serializers import NestedHyperlinkedModelSerializer
1012
from pypi_attestations import AttestationError
1113
from pydantic import TypeAdapter, ValidationError
1214
from urllib.parse import urljoin
1315

16+
# todo: cannot import from pulpcore.plugin!
17+
from pulpcore.app.serializers import NestedIdentityField
18+
1419
from pulpcore.plugin import models as core_models
1520
from pulpcore.plugin import serializers as core_serializers
1621
from pulpcore.plugin.util import get_domain, get_prn, get_current_authenticated_user
@@ -780,6 +785,106 @@ class Meta:
780785
model = python_models.PythonRemote
781786

782787

788+
class PythonBlocklistEntrySerializer(
789+
core_serializers.ModelSerializer, NestedHyperlinkedModelSerializer
790+
):
791+
"""
792+
Serializer for PythonBlocklistEntry.
793+
794+
The `repository` is supplied by the URL (not the request body) and is injected
795+
by the viewset before saving.
796+
"""
797+
798+
pulp_href = NestedIdentityField(
799+
view_name="blocklist_entries-detail",
800+
parent_lookup_kwargs={"repository_pk": "repository__pk"},
801+
)
802+
repository = core_serializers.DetailRelatedField(
803+
read_only=True,
804+
view_name_pattern=r"repositories(-.*/.*)?-detail",
805+
help_text=_("Repository this blocklist entry belongs to."),
806+
)
807+
name = serializers.CharField(
808+
required=False,
809+
allow_null=True,
810+
default=None,
811+
help_text=_(
812+
"Package name to block (for all versions). Compared after PEP 503 normalization. "
813+
"Required when 'filename' is not provided."
814+
),
815+
)
816+
version = serializers.CharField(
817+
required=False,
818+
allow_null=True,
819+
default=None,
820+
help_text=_("Exact version string to block (e.g. '1.0'). Only used when 'name' is set."),
821+
)
822+
filename = serializers.CharField(
823+
required=False,
824+
allow_null=True,
825+
default=None,
826+
help_text=_("Exact filename to block. Required when 'name' is not provided."),
827+
)
828+
added_by = serializers.CharField(read_only=True)
829+
830+
def validate(self, data):
831+
"""
832+
Validate that the blocklist entry is well-formed and not a duplicate.
833+
"""
834+
name = data.get("name")
835+
filename = data.get("filename")
836+
version = data.get("version")
837+
838+
if version and filename:
839+
raise serializers.ValidationError(_("'version' cannot be used with 'filename'."))
840+
if version and not name:
841+
raise serializers.ValidationError(_("'version' requires 'name' to be provided."))
842+
if name and filename:
843+
raise serializers.ValidationError(_("'name' and 'filename' are mutually exclusive."))
844+
if not name and not filename:
845+
raise serializers.ValidationError(_("Either 'name' or 'filename' must be provided."))
846+
847+
if version:
848+
try:
849+
Version(version)
850+
except InvalidVersion:
851+
raise serializers.ValidationError(
852+
{"version": _("'{}' is not a valid version.").format(version)}
853+
)
854+
855+
repository = self.context.get("repository")
856+
if repository:
857+
qs = python_models.PythonBlocklistEntry.objects.filter(repository=repository)
858+
if name and qs.filter(name=name, version=version).exists():
859+
raise serializers.ValidationError(
860+
_("A blocklist entry with this name and version already exists.")
861+
)
862+
if filename and qs.filter(filename=filename).exists():
863+
raise serializers.ValidationError(
864+
_("A blocklist entry with this filename already exists.")
865+
)
866+
867+
return data
868+
869+
def create(self, validated_data):
870+
"""
871+
Create a new blocklist entry, recording the authenticated user in `added_by`.
872+
"""
873+
user = get_current_authenticated_user() # todo?
874+
validated_data["added_by"] = user.username if user else ""
875+
return super().create(validated_data)
876+
877+
class Meta:
878+
fields = core_serializers.ModelSerializer.Meta.fields + (
879+
"repository",
880+
"name",
881+
"version",
882+
"filename",
883+
"added_by",
884+
)
885+
model = python_models.PythonBlocklistEntry
886+
887+
783888
class PythonBanderRemoteSerializer(serializers.Serializer):
784889
"""
785890
A Serializer for the initial step of creating a Python Remote from a Bandersnatch config file

pulp_python/app/viewsets.py

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,16 @@
77
from pathlib import Path
88
from rest_framework import status
99
from rest_framework.decorators import action
10+
from rest_framework.mixins import (
11+
CreateModelMixin,
12+
DestroyModelMixin,
13+
ListModelMixin,
14+
RetrieveModelMixin,
15+
)
1016
from rest_framework.response import Response
1117
from rest_framework.serializers import ValidationError
1218

19+
1320
from pulpcore.plugin import viewsets as core_viewsets
1421
from pulpcore.plugin.actions import ModifyRepositoryActionMixin
1522
from pulpcore.plugin.models import RepositoryVersion
@@ -28,7 +35,7 @@
2835

2936
class PythonRepositoryViewSet(
3037
core_viewsets.RepositoryViewSet, ModifyRepositoryActionMixin, core_viewsets.RolesMixin
31-
):
38+
): # todo? remove ModifyRepositoryActionMixin?
3239
"""
3340
PythonRepository represents a single Python repository, to which content can be
3441
synced, added, or removed.
@@ -136,7 +143,7 @@ class PythonRepositoryViewSet(
136143
responses={202: AsyncOperationResponseSerializer},
137144
)
138145
@action(detail=True, methods=["post"], serializer_class=RepositoryAddRemoveContentSerializer)
139-
def modify(self, request, pk):
146+
def modify(self, request, pk): # todo?
140147
"""
141148
Queues a task that creates a new RepositoryVersion by adding and removing content units.
142149
@@ -216,6 +223,65 @@ def sync(self, request, pk):
216223
return core_viewsets.OperationPostponedResponse(result, request)
217224

218225

226+
class PythonBlocklistEntryViewSet(
227+
core_viewsets.NamedModelViewSet,
228+
CreateModelMixin,
229+
RetrieveModelMixin,
230+
ListModelMixin,
231+
DestroyModelMixin,
232+
):
233+
"""
234+
ViewSet for managing blocklist entries on a PythonRepository.
235+
236+
Blocklist entries prevent packages from being uploaded to the repository.
237+
Each entry can match by exact filename, package name, or package name with an exact version.
238+
"""
239+
240+
endpoint_name = "blocklist_entries"
241+
router_lookup = "pythonblocklistentry"
242+
parent_viewset = PythonRepositoryViewSet
243+
parent_lookup_kwargs = {"repository_pk": "repository__pk"}
244+
serializer_class = python_serializers.PythonBlocklistEntrySerializer
245+
queryset = python_models.PythonBlocklistEntry.objects.all()
246+
ordering = ("-pulp_created",)
247+
248+
DEFAULT_ACCESS_POLICY = {
249+
"statements": [
250+
{
251+
"action": ["list", "retrieve"],
252+
"principal": "authenticated",
253+
"effect": "allow",
254+
"condition": "has_repository_model_or_domain_or_obj_perms:python.view_pythonrepository", # noqa: E501
255+
},
256+
{
257+
"action": ["create", "destroy"],
258+
"principal": "authenticated",
259+
"effect": "allow",
260+
"condition": [
261+
"has_repository_model_or_domain_or_obj_perms:python.modify_pythonrepository",
262+
"has_repository_model_or_domain_or_obj_perms:python.view_pythonrepository",
263+
],
264+
},
265+
],
266+
}
267+
268+
def get_serializer_context(self):
269+
"""
270+
Inject the parent repository into the serializer context so that `validate()` can check for
271+
duplicate entries. The guard on `repository_pk` prevents errors during schema generation.
272+
"""
273+
context = super().get_serializer_context()
274+
if self.kwargs.get("repository_pk"):
275+
context["repository"] = self.get_parent_object()
276+
return context
277+
278+
def perform_create(self, serializer):
279+
"""
280+
Set the repository FK from the URL before saving the entry.
281+
"""
282+
serializer.save(repository=self.get_parent_object())
283+
284+
219285
class PythonRepositoryVersionViewSet(core_viewsets.RepositoryVersionViewSet):
220286
"""
221287
PythonRepositoryVersion represents a single Python repository version.

0 commit comments

Comments
 (0)