55from django .db .utils import IntegrityError
66from packaging .requirements import Requirement
77from 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
1111from pulpcore .plugin import models as core_models
1212from 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
1515from 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+ )
1622from 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