Skip to content

Commit 3093b0c

Browse files
authored
Merge pull request #1187 from jobselko/1166
[PULP-1496] Add repository-specific package blocklist
2 parents 71cbb2b + de2a7d8 commit 3093b0c

File tree

8 files changed

+538
-3
lines changed

8 files changed

+538
-3
lines changed

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/_SUMMARY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@
44
* [Host Python Content](host.md)
55
* [Vulnerability Report](vulnerability_report.md)
66
* [Attestation Hosting](attestation.md)
7+
* [Package Blocklist](blocklist.md)

docs/user/guides/blocklist.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# Package Blocklist
2+
3+
A repository can have a blocklist that prevents specific packages from being added.
4+
Blocklist entries can match by package `name` (all versions), package `name` with an exact `version`, or exact `filename`.
5+
Exactly one of `name` or `filename` must be provided.
6+
7+
Each entry records the PRN of the user who created it in the `added_by` field.
8+
9+
## Setup
10+
11+
If you do not already have a repository, create one:
12+
13+
```bash
14+
pulp python repository create --name foo
15+
```
16+
17+
Set the API base URL and repository HREF for use in the subsequent commands:
18+
19+
```bash
20+
PULP_API="http://localhost:5001"
21+
REPO_HREF=$(pulp python repository show --name foo | jq -r ".pulp_href")
22+
```
23+
24+
## Add a blocklist entry
25+
26+
=== "By name (all versions)"
27+
28+
```bash
29+
# Block all versions of shelf-reader
30+
http POST "${PULP_API}${REPO_HREF}blocklist_entries/" name="shelf-reader"
31+
```
32+
33+
=== "By name and version"
34+
35+
```bash
36+
# Block only shelf-reader 0.1
37+
http POST "${PULP_API}${REPO_HREF}blocklist_entries/" name="shelf-reader" version="0.1"
38+
```
39+
40+
=== "By filename"
41+
42+
```bash
43+
# Block only shelf-reader-0.1.tar.gz
44+
http POST "${PULP_API}${REPO_HREF}blocklist_entries/" filename="shelf-reader-0.1.tar.gz"
45+
```
46+
47+
Set the UUID of a created entry for use in the subsequent commands:
48+
49+
```bash
50+
ENTRY_UUID=$(http GET "${PULP_API}${REPO_HREF}blocklist_entries/" | jq -r '.results[0].prn | split(":") | .[-1]')
51+
```
52+
53+
## List blocklist entries
54+
55+
List all entries for a repository:
56+
57+
```bash
58+
http GET "${PULP_API}${REPO_HREF}blocklist_entries/"
59+
```
60+
61+
Show a single entry:
62+
63+
```bash
64+
http GET "${PULP_API}${REPO_HREF}blocklist_entries/${ENTRY_UUID}/"
65+
```
66+
67+
## Remove a blocklist entry
68+
69+
```bash
70+
http DELETE "${PULP_API}${REPO_HREF}blocklist_entries/${ENTRY_UUID}/"
71+
```
72+
73+
Once an entry is removed, packages matching it can be added to the repository again.
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-16 14:00
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(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: 64 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,63 @@ 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+
Check newly added content in a repository version against the blocklist.
429+
"""
430+
added_content = PythonPackageContent.objects.filter(
431+
pk__in=new_version.added().values_list("pk", flat=True)
432+
).only("filename", "name_normalized", "version")
433+
if added_content.exists():
434+
self.check_blocklist_for_packages(added_content)
435+
436+
def check_blocklist_for_packages(self, packages):
437+
"""
438+
Raise a ValidationError if any of the given packages match a blocklist entry.
439+
"""
440+
entries = PythonBlocklistEntry.objects.filter(repository=self)
441+
if not entries.exists():
442+
return
443+
444+
blocked = []
445+
for pkg in packages:
446+
for entry in entries:
447+
if entry.filename and entry.filename == pkg.filename:
448+
blocked.append(pkg.filename)
449+
break
450+
if entry.name == pkg.name_normalized:
451+
if not entry.version or entry.version == pkg.version:
452+
blocked.append(pkg.filename)
453+
break
454+
if blocked:
455+
raise ValidationError(
456+
"Blocklisted packages cannot be added to this repository: "
457+
"{}".format(", ".join(blocked))
458+
)
459+
460+
461+
class PythonBlocklistEntry(BaseModel):
462+
"""
463+
An entry in a PythonRepository's package blocklist.
464+
465+
Blocklist entries prevent packages from being added to the repository.
466+
Entries can match by package `name` (all versions), package `name` + `version`,
467+
or exact `filename`. Exactly one of `name` or `filename` must be provided.
468+
"""
469+
470+
name = models.TextField(null=True, default=None)
471+
version = models.TextField(null=True, default=None)
472+
filename = models.TextField(null=True, default=None)
473+
added_by = models.TextField(default="")
474+
repository = models.ForeignKey(
475+
PythonRepository, on_delete=models.CASCADE, related_name="blocklist_entries"
476+
)
477+
478+
def __str__(self):
479+
if self.filename:
480+
return f"<{self._meta.object_name}: {self.filename}>"
481+
return f"<{self._meta.object_name}: {self.name} [{self.version or 'all'}]>"
482+
483+
class Meta:
484+
default_related_name = "%(app_label)s_%(model_name)s"

pulp_python/app/serializers.py

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,18 @@
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
1011
from pypi_attestations import AttestationError
1112
from pydantic import TypeAdapter, ValidationError
1213
from urllib.parse import urljoin
1314

1415
from pulpcore.plugin import models as core_models
1516
from pulpcore.plugin import serializers as core_serializers
16-
from pulpcore.plugin.util import get_domain, get_prn, get_current_authenticated_user
17+
from pulpcore.plugin.util import get_domain, get_prn, get_current_authenticated_user, reverse
1718

1819
from pulp_python.app import models as python_models
20+
from pulp_python.app.utils import canonicalize_name
1921
from pulp_python.app.provenance import (
2022
Attestation,
2123
Provenance,
@@ -53,6 +55,11 @@ class PythonRepositorySerializer(core_serializers.RepositorySerializer):
5355
default=False,
5456
required=False,
5557
)
58+
blocklist_entries_href = serializers.SerializerMethodField(
59+
help_text=_("URL to the blocklist entries for this repository."),
60+
read_only=True,
61+
)
62+
5663
allow_package_substitution = serializers.BooleanField(
5764
help_text=_(
5865
"Whether to allow package substitution (replacing existing packages with packages "
@@ -65,10 +72,15 @@ class PythonRepositorySerializer(core_serializers.RepositorySerializer):
6572
required=False,
6673
)
6774

75+
def get_blocklist_entries_href(self, obj):
76+
repo_href = reverse("repositories-python/python-detail", kwargs={"pk": obj.pk})
77+
return f"{repo_href}blocklist_entries/"
78+
6879
class Meta:
6980
fields = core_serializers.RepositorySerializer.Meta.fields + (
7081
"autopublish",
7182
"allow_package_substitution",
83+
"blocklist_entries_href",
7284
)
7385
model = python_models.PythonRepository
7486

@@ -780,6 +792,115 @@ class Meta:
780792
model = python_models.PythonRemote
781793

782794

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

0 commit comments

Comments
 (0)