Skip to content

Commit 1ff7d38

Browse files
committed
Add attestation upload support
fixes: #984
1 parent 4753a42 commit 1ff7d38

File tree

11 files changed

+351
-30
lines changed

11 files changed

+351
-30
lines changed

CHANGES/984.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added attestations field to package upload that will create a PEP 740 Provenance object for that content.

MANIFEST.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ include functest_requirements.txt
99
include test_requirements.txt
1010
include unittest_requirements.txt
1111
include pulp_python/app/webserver_snippets/*
12+
include pulp_python/tests/functional/assets/*
1213
exclude releasing.md

pulp_python/app/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@
2121
)
2222
from pulpcore.plugin.responses import ArtifactResponse
2323

24-
from pypi_attestations import Provenance
2524
from pathlib import PurePath
25+
from .provenance import Provenance
2626
from .utils import (
2727
artifact_to_python_content_data,
2828
canonicalize_name,

pulp_python/app/provenance.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
from typing import Annotated, Literal, Union, get_args
2+
3+
from pydantic import BaseModel, ConfigDict, Field
4+
from pydantic.alias_generators import to_snake
5+
from pypi_attestations import Attestation, Distribution, Publisher
6+
7+
8+
class _PermissivePolicy:
9+
"""A permissive verification policy that always succeeds."""
10+
11+
def verify(self, cert):
12+
"""Always succeed verification."""
13+
pass
14+
15+
16+
class AnyPublisher(BaseModel):
17+
"""A fallback publisher for any kind not matching other publisher types."""
18+
19+
model_config = ConfigDict(alias_generator=to_snake, extra="allow")
20+
21+
kind: str
22+
23+
def _as_policy(self):
24+
"""Return a permissive policy that always succeed."""
25+
return _PermissivePolicy()
26+
27+
28+
# Get the underlying Union type of the original Publisher
29+
# Publisher is Annotated[Union[...], Field(discriminator="kind")]
30+
_OriginalPublisherTypes = get_args(Publisher.__origin__)
31+
# Add AnyPublisher to the list of original publisher types
32+
_ExtendedPublisherTypes = (*_OriginalPublisherTypes, AnyPublisher)
33+
_ExtendedPublisherUnion = Union[_ExtendedPublisherTypes]
34+
# Create a new type that fallbacks to AnyPublisher
35+
ExtendedPublisher = Annotated[_ExtendedPublisherUnion, Field(union_mode="left_to_right")]
36+
37+
38+
class AttestationBundle(BaseModel):
39+
"""
40+
AttestationBundle object as defined in PEP740.
41+
42+
PyPI only accepts attestations from TrustedPublishers (GitHub, GitLab, Google), but we will
43+
accept from any user.
44+
"""
45+
46+
publisher: ExtendedPublisher
47+
attestations: list[Attestation]
48+
49+
50+
class Provenance(BaseModel):
51+
"""Provenance object as defined in PEP740."""
52+
53+
version: Literal[1] = 1
54+
attestation_bundles: list[AttestationBundle]
55+
56+
57+
def verify_provenance(filename, sha256, provenance, offline=False):
58+
"""Verify the provenance object is valid for the package."""
59+
dist = Distribution(name=filename, digest=sha256)
60+
for bundle in provenance.attestation_bundles:
61+
publisher = bundle.publisher
62+
policy = publisher._as_policy()
63+
for attestation in bundle.attestations:
64+
bundle = attestation.to_bundle()
65+
checkpoint = bundle.log_entry._inner.inclusion_proof.checkpoint
66+
staging = "sigstage.dev" in checkpoint.envelope
67+
attestation.verify(policy, dist, staging=staging, offline=offline)

pulp_python/app/pypi/serializers.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
from gettext import gettext as _
33

44
from rest_framework import serializers
5+
from pydantic import TypeAdapter, ValidationError
6+
from pypi_attestations import Attestation
57
from pulp_python.app.utils import DIST_EXTENSIONS, SUPPORTED_METADATA_VERSIONS
68
from pulpcore.plugin.models import Artifact
79
from pulpcore.plugin.util import get_domain
@@ -70,6 +72,11 @@ class PackageUploadSerializer(serializers.Serializer):
7072
required=False,
7173
choices=SUPPORTED_METADATA_VERSIONS,
7274
)
75+
attestations = serializers.JSONField(
76+
required=False,
77+
help_text=_("A JSON list containing attestations for the package."),
78+
write_only=True,
79+
)
7380

7481
def validate(self, data):
7582
"""Validates the request."""
@@ -98,6 +105,14 @@ def validate(self, data):
98105
}
99106
)
100107

108+
if attestations := data.get("attestations"):
109+
try:
110+
attestations = TypeAdapter(list[Attestation]).validate_python(attestations)
111+
except ValidationError as e:
112+
raise serializers.ValidationError(
113+
{"attestations": _("The uploaded attestations are not valid: {}".format(e))}
114+
)
115+
101116
sha256 = data.get("sha256_digest")
102117
digests = {"sha256": sha256} if sha256 else None
103118
artifact = Artifact.init_and_validate(file, expected_digests=digests)

pulp_python/app/pypi/views.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -181,53 +181,63 @@ def upload(self, request, path):
181181
serializer = PackageUploadSerializer(data=request.data)
182182
serializer.is_valid(raise_exception=True)
183183
artifact, filename = serializer.validated_data["content"]
184+
attestations = serializer.validated_data.get("attestations", None)
184185
repo_content = self.get_content(self.get_repository_version(self.distribution))
185186
if repo_content.filter(filename=filename).exists():
186187
return HttpResponseBadRequest(reason=f"Package {filename} already exists in index")
187188

188189
if settings.PYTHON_GROUP_UPLOADS:
189-
return self.upload_package_group(repo, artifact, filename, request.session)
190+
return self.upload_package_group(
191+
repo, artifact, filename, attestations, request.session
192+
)
190193

191194
result = dispatch(
192195
tasks.upload,
193196
exclusive_resources=[artifact, repo],
194197
kwargs={
195198
"artifact_sha256": artifact.sha256,
196199
"filename": filename,
200+
"attestations": attestations,
197201
"repository_pk": str(repo.pk),
198202
},
199203
)
200204
return OperationPostponedResponse(result, request)
201205

202-
def upload_package_group(self, repo, artifact, filename, session):
206+
def upload_package_group(self, repo, artifact, filename, attestations, session):
203207
"""Steps 4 & 5, spawns tasks to add packages to index."""
204208
start_time = datetime.now(tz=timezone.utc) + timedelta(seconds=5)
205209
task = "updated"
206210
if not session.get("start"):
207-
task = self.create_group_upload_task(session, repo, artifact, filename, start_time)
211+
task = self.create_group_upload_task(
212+
session, repo, artifact, filename, attestations, start_time
213+
)
208214
else:
209215
sq = Session.objects.select_for_update(nowait=True).filter(pk=session.session_key)
210216
try:
211217
with transaction.atomic():
212218
sq.first()
213219
current_start = datetime.fromisoformat(session["start"])
214220
if current_start >= datetime.now(tz=timezone.utc):
215-
session["artifacts"].append((str(artifact.sha256), filename))
221+
session["artifacts"].append((str(artifact.sha256), filename, attestations))
216222
session["start"] = str(start_time)
217223
session.modified = False
218224
session.save()
219225
else:
220226
raise DatabaseError
221227
except DatabaseError:
222228
session.cycle_key()
223-
task = self.create_group_upload_task(session, repo, artifact, filename, start_time)
229+
task = self.create_group_upload_task(
230+
session, repo, artifact, filename, attestations, start_time
231+
)
224232
data = {"session": session.session_key, "task": task, "task_start_time": start_time}
225233
return Response(data=data)
226234

227-
def create_group_upload_task(self, cur_session, repository, artifact, filename, start_time):
235+
def create_group_upload_task(
236+
self, cur_session, repository, artifact, filename, attestations, start_time
237+
):
228238
"""Creates the actual task that adds the packages to the index."""
229239
cur_session["start"] = str(start_time)
230-
cur_session["artifacts"] = [(str(artifact.sha256), filename)]
240+
cur_session["artifacts"] = [(str(artifact.sha256), filename, attestations)]
231241
cur_session.modified = False
232242
cur_session.save()
233243
task = dispatch(

pulp_python/app/serializers.py

Lines changed: 81 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,20 @@
55
from django.db.utils import IntegrityError
66
from packaging.requirements import Requirement
77
from rest_framework import serializers
8-
from pydantic import ValidationError
9-
from pypi_attestations import Distribution, Provenance, VerificationError
8+
from pydantic import TypeAdapter, ValidationError
9+
from pypi_attestations import Attestation, VerificationError
1010

1111
from pulpcore.plugin import models as core_models
1212
from pulpcore.plugin import serializers as core_serializers
13-
from pulpcore.plugin.util import get_domain
13+
from pulpcore.plugin.util import get_domain, get_prn, get_current_authenticated_user
1414

1515
from pulp_python.app import models as python_models
16+
from pulp_python.app.provenance import (
17+
Provenance,
18+
verify_provenance,
19+
AttestationBundle,
20+
AnyPublisher,
21+
)
1622
from pulp_python.app.utils import (
1723
DIST_EXTENSIONS,
1824
artifact_to_python_content_data,
@@ -296,6 +302,46 @@ class PythonPackageContentSerializer(core_serializers.SingleArtifactContentUploa
296302
allow_null=True,
297303
help_text=_("The SHA256 digest of the package's METADATA file."),
298304
)
305+
# PEP740 Attestations/Provenance
306+
attestations = serializers.JSONField(
307+
required=False,
308+
help_text=_("A JSON list containing attestations for the package."),
309+
write_only=True,
310+
)
311+
provenance = serializers.SerializerMethodField(
312+
read_only=True, help_text=_("The created provenance object on upload.")
313+
)
314+
315+
def get_provenance(self, obj):
316+
"""Get the provenance for the package."""
317+
if provenance := getattr(obj, "provenance", None):
318+
return get_prn(provenance)
319+
return None
320+
321+
def validate_attestations(self, value):
322+
"""Validate the attestations, turn into Attestation objects."""
323+
try:
324+
if isinstance(value, str):
325+
attestations = TypeAdapter(list[Attestation]).validate_json(value)
326+
else:
327+
attestations = TypeAdapter(list[Attestation]).validate_python(value)
328+
except ValidationError as e:
329+
raise serializers.ValidationError(_("Invalid attestations: {}".format(e)))
330+
return attestations
331+
332+
def handle_attestations(self, filename, sha256, attestations, offline=False):
333+
"""Handle converting attestations to a Provenance object."""
334+
user = get_current_authenticated_user()
335+
publisher = AnyPublisher(kind="Pulp User", prn=get_prn(user))
336+
att_bundle = AttestationBundle(publisher=publisher, attestations=attestations)
337+
provenance = Provenance(attestation_bundles=[att_bundle])
338+
try:
339+
verify_provenance(filename, sha256, provenance, offline=offline)
340+
except VerificationError as e:
341+
raise serializers.ValidationError(
342+
{"attestations": _("Attestations failed verification: {}".format(e))}
343+
)
344+
return provenance.model_dump(mode="json")
299345

300346
def deferred_validate(self, data):
301347
"""
@@ -336,6 +382,8 @@ def deferred_validate(self, data):
336382
)
337383

338384
data.update(_data)
385+
if attestations := data.pop("attestations", None):
386+
data["provenance"] = self.handle_attestations(filename, data["sha256"], attestations)
339387

340388
return data
341389

@@ -345,6 +393,29 @@ def retrieve(self, validated_data):
345393
)
346394
return content.first()
347395

396+
def create(self, validated_data):
397+
"""Create new PythonPackageContent object."""
398+
repository = validated_data.pop("repository", None)
399+
provenance = validated_data.pop("provenance", None)
400+
content = super().create(validated_data)
401+
if provenance:
402+
prov_sha256 = python_models.PackageProvenance.calculate_sha256(provenance)
403+
prov_model, _ = python_models.PackageProvenance.objects.get_or_create(
404+
sha256=prov_sha256,
405+
_pulp_domain=get_domain(),
406+
defaults={"package": content, "provenance": provenance},
407+
)
408+
if core_models.Task.current():
409+
core_models.CreatedResource.objects.create(content_object=prov_model)
410+
setattr(content, "provenance", prov_model)
411+
if repository:
412+
repository.cast()
413+
content_to_add = [content.pk, content.provenance.pk] if provenance else [content.pk]
414+
content_to_add = core_models.Content.objects.filter(pk__in=content_to_add)
415+
with repository.new_version() as new_version:
416+
new_version.add_content(content_to_add)
417+
return content
418+
348419
class Meta:
349420
fields = core_serializers.SingleArtifactContentUploadSerializer.Meta.fields + (
350421
"author",
@@ -381,6 +452,8 @@ class Meta:
381452
"size",
382453
"sha256",
383454
"metadata_sha256",
455+
"attestations",
456+
"provenance",
384457
)
385458
model = python_models.PythonPackageContent
386459

@@ -436,6 +509,10 @@ def validate(self, data):
436509
data.update(parse_project_metadata(vars(metadata)))
437510
# Overwrite filename from metadata
438511
data["filename"] = filename
512+
if attestations := data.pop("attestations", None):
513+
data["provenance"] = self.handle_attestations(
514+
filename, data["sha256"], attestations, offline=True
515+
)
439516
return data
440517

441518
class Meta(PythonPackageContentSerializer.Meta):
@@ -497,13 +574,8 @@ def deferred_validate(self, data):
497574
_("The uploaded provenance is not valid: {}".format(e))
498575
)
499576
if data.pop("verify"):
500-
dist = Distribution(name=data["package"].filename, digest=data["package"].sha256)
501577
try:
502-
for attestation_bundle in provenance.attestation_bundles:
503-
publisher = attestation_bundle.publisher
504-
policy = publisher._as_policy()
505-
for attestation in attestation_bundle.attestations:
506-
attestation.verify(policy, dist)
578+
verify_provenance(data["package"].filename, data["package"].sha256, provenance)
507579
except VerificationError as e:
508580
raise serializers.ValidationError(_("Provenance verification failed: {}".format(e)))
509581
return data

0 commit comments

Comments
 (0)