@@ -28,6 +28,7 @@ import (
2828 "github.com/chainloop-dev/chainloop/app/controlplane/pkg/auditor/events"
2929 "github.com/chainloop-dev/chainloop/app/controlplane/pkg/pagination"
3030 "github.com/chainloop-dev/chainloop/pkg/attestation"
31+ attestationapi "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
3132 "github.com/chainloop-dev/chainloop/pkg/attestation/renderer/chainloop"
3233 "github.com/chainloop-dev/chainloop/pkg/attestation/verifier"
3334 "github.com/chainloop-dev/chainloop/pkg/cache/attestationbundle"
@@ -123,11 +124,12 @@ type WorkflowRunRepo interface {
123124}
124125
125126type WorkflowRunUseCase struct {
126- wfRunRepo WorkflowRunRepo
127- wfRepo WorkflowRepo
128- orgRepo OrganizationRepo
129- logger * log.Helper
130- auditorUC * AuditorUseCase
127+ wfRunRepo WorkflowRunRepo
128+ wfRepo WorkflowRepo
129+ orgRepo OrganizationRepo
130+ contractRepo WorkflowContractRepo
131+ logger * log.Helper
132+ auditorUC * AuditorUseCase
131133
132134 signingUseCase * SigningUseCase
133135 bundleCache * attestationbundle.Cache
@@ -139,6 +141,7 @@ type WorkflowRunUseCaseOpts struct {
139141 WfrRepo WorkflowRunRepo
140142 WfRepo WorkflowRepo
141143 OrgRepo OrganizationRepo
144+ ContractRepo WorkflowContractRepo
142145 SigningUC * SigningUseCase
143146 AuditorUC * AuditorUseCase
144147 Logger log.Logger
@@ -157,6 +160,7 @@ func NewWorkflowRunUseCase(opts *WorkflowRunUseCaseOpts) (*WorkflowRunUseCase, e
157160 wfRunRepo : opts .WfrRepo ,
158161 wfRepo : opts .WfRepo ,
159162 orgRepo : opts .OrgRepo ,
163+ contractRepo : opts .ContractRepo ,
160164 auditorUC : opts .AuditorUC ,
161165 signingUseCase : opts .SigningUC ,
162166 logger : log .NewHelper (logger ),
@@ -413,6 +417,85 @@ func (uc *WorkflowRunUseCase) orgBlocksReleasedVersions(ctx context.Context, run
413417 return org .BlockAttestationsOnReleasedVersions , nil
414418}
415419
420+ // ValidateAttestationContract checks a bundle against the contract revision
421+ // pinned on its workflow run without persisting anything.
422+ //
423+ // SaveAttestation runs the same check and is the authoritative one, since it
424+ // sits on the path every attestation takes. This entry point exists for callers
425+ // that push the bundle to a CAS backend before calling SaveAttestation: without
426+ // it, an attestation rejected for violating its contract would already have left
427+ // a blob behind in CAS.
428+ func (uc * WorkflowRunUseCase ) ValidateAttestationContract (ctx context.Context , runID string , bundle []byte ) error {
429+ ctx , span := otelx .Start (ctx , workflowRunTracer , "WorkflowRunUseCase.ValidateAttestationContract" )
430+ defer span .End ()
431+
432+ id , err := uuid .Parse (runID )
433+ if err != nil {
434+ return NewErrInvalidUUID (err )
435+ }
436+
437+ run , err := uc .wfRunRepo .FindByID (ctx , id )
438+ if err != nil {
439+ return fmt .Errorf ("finding workflow run: %w" , err )
440+ } else if run == nil {
441+ return NewErrNotFound ("workflow run" )
442+ }
443+
444+ dsseEnv , err := attestation .DSSEEnvelopeFromBundleBytes (bundle )
445+ if err != nil {
446+ return fmt .Errorf ("extracting DSSE envelope: %w" , err )
447+ }
448+
449+ predicate , err := chainloop .ExtractPredicate (dsseEnv )
450+ if err != nil {
451+ return fmt .Errorf ("extracting predicate: %w" , err )
452+ }
453+
454+ return uc .validateAgainstContract (ctx , run , predicate )
455+ }
456+
457+ // validateAgainstContract rejects an attestation that does not satisfy the
458+ // contract revision pinned on the workflow run when it was initialized.
459+ //
460+ // The CLI runs the same check before pushing, but it signs the bundle with the
461+ // same client that decided whether to run it, so a valid signature says nothing
462+ // about contract compliance. The control plane is the authority here.
463+ func (uc * WorkflowRunUseCase ) validateAgainstContract (ctx context.Context , run * WorkflowRun , predicate chainloop.NormalizablePredicate ) error {
464+ // Every run created through the attestation init endpoint pins a contract
465+ // revision, so a run without one is an integrity problem rather than a
466+ // reason to skip the check.
467+ if run .ContractVersionID == uuid .Nil {
468+ return NewErrValidation (errors .New ("workflow run has no contract revision associated" ))
469+ }
470+
471+ contract , err := uc .contractRepo .FindVersionByID (ctx , run .ContractVersionID )
472+ if err != nil {
473+ return fmt .Errorf ("finding contract version: %w" , err )
474+ } else if contract == nil || contract .Version == nil || contract .Version .Schema == nil {
475+ return NewErrNotFound ("contract version" )
476+ }
477+
478+ // Schema is the v1 form of the contract and is populated for v2 contracts
479+ // too, so this covers both contract formats. A revision we cannot read is an
480+ // error rather than an empty contract: treating it as "nothing required"
481+ // would silently wave the attestation through.
482+ schema := contract .Version .Schema .Schema
483+ if schema == nil {
484+ return NewErrValidation (fmt .Errorf ("contract revision %d could not be read" , run .ContractRevisionUsed ))
485+ }
486+
487+ craftedNames := make (map [string ]struct {}, len (predicate .GetMaterials ()))
488+ for _ , m := range predicate .GetMaterials () {
489+ craftedNames [m .Name ] = struct {}{}
490+ }
491+
492+ if err := attestationapi .ValidateMaterialsPresence (schema .GetMaterials (), craftedNames ); err != nil {
493+ return NewErrValidation (fmt .Errorf ("attestation does not satisfy contract revision %d: %w" , run .ContractRevisionUsed , err ))
494+ }
495+
496+ return nil
497+ }
498+
416499func (uc * WorkflowRunUseCase ) SaveAttestation (ctx context.Context , id string , bundle []byte , opts ... SaveAttestationOption ) (* v1.Hash , error ) {
417500 ctx , span := otelx .Start (ctx , workflowRunTracer , "WorkflowRunUseCase.SaveAttestation" )
418501 defer span .End ()
@@ -499,6 +582,10 @@ func (uc *WorkflowRunUseCase) SaveAttestation(ctx context.Context, id string, bu
499582 }
500583 }
501584
585+ if err := uc .validateAgainstContract (ctx , run , predicate ); err != nil {
586+ return nil , err
587+ }
588+
502589 if options .skipBundlePersistence {
503590 if err := uc .wfRunRepo .SaveAttestationDigest (ctx , runID , digest .String (), blockReleasedVersions ); err != nil {
504591 return nil , fmt .Errorf ("saving attestation digest: %w" , err )
0 commit comments