Skip to content

Commit 9aa7066

Browse files
authored
feat(controlplane): enforce workflow contracts when attestations are stored (#3465)
1 parent 4641808 commit 9aa7066

9 files changed

Lines changed: 670 additions & 8 deletions

File tree

‎app/controlplane/cmd/wire_gen.go‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎app/controlplane/internal/service/attestation.go‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,14 @@ func (s *AttestationService) storeAttestation(ctx context.Context, bundle []byte
336336
return nil, handleUseCaseErr(hashErr, s.log)
337337
}
338338

339+
// On this path the bundle reaches CAS before SaveAttestation gets to
340+
// validate it, so the contract is checked up front. Otherwise an
341+
// attestation rejected for violating its contract would still have left
342+
// a blob behind in the CAS backend.
343+
if err = s.wrUseCase.ValidateAttestationContract(ctx, workflowRunID, bundle); err != nil {
344+
return nil, handleUseCaseErr(err, s.log)
345+
}
346+
339347
if err = s.uploadAttestationToCASWithRetry(ctx, bundle, casBackend, workflowRunID, digestHash); err != nil {
340348
return nil, handleUseCaseErr(err, s.log)
341349
}

‎app/controlplane/internal/service/service_test.go‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,16 @@ func TestHandleUseCaseErr(t *testing.T) {
9696
wantCode: codes.InvalidArgument,
9797
wantMessage: "validation error: invalid input",
9898
},
99+
{
100+
// An attestation rejected for not satisfying its contract must reach
101+
// the CI job as a client error naming what was missing, not as a
102+
// masked 500 the operator cannot act on.
103+
name: "contract violation reaches the client as a bad request",
104+
err: biz.NewErrValidation(fmt.Errorf("attestation does not satisfy contract revision 4: %w",
105+
errors.New("some materials have not been crafted yet: sbom, sarif"))),
106+
wantCode: codes.InvalidArgument,
107+
wantMessage: "validation error: attestation does not satisfy contract revision 4: some materials have not been crafted yet: sbom, sarif",
108+
},
99109
{
100110
name: "unknown error is masked as internal server error",
101111
err: errors.New("sensitive details"),

‎app/controlplane/pkg/biz/testhelpers/wire_gen.go‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎app/controlplane/pkg/biz/workflowrun.go‎

Lines changed: 92 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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

125126
type 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+
416499
func (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

Comments
 (0)