Skip to content

Commit 618f074

Browse files
committed
Bind approval evidence after package verification
1 parent 5dc00ae commit 618f074

5 files changed

Lines changed: 128 additions & 25 deletions

File tree

boatstack/flow/softwaredelivery/workpackage/package.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,15 @@ type Result struct {
171171

172172
type CurrentProgram struct{ ProgramFingerprint, WorkContractFingerprint string }
173173

174+
// VerifiedApproval is the exact portable approval evidence read after full
175+
// package verification. Raw contains the canonical approval bytes validated
176+
// against Manifest.
177+
type VerifiedApproval struct {
178+
Manifest Manifest
179+
Approval Approval
180+
Raw []byte
181+
}
182+
174183
func Encode(value any) ([]byte, error) {
175184
raw, err := json.MarshalIndent(value, "", " ")
176185
if err != nil {
@@ -443,6 +452,59 @@ func Verify(repository, deliveryID, packageFingerprint string, current *CurrentP
443452
return result
444453
}
445454

455+
// ReadVerifiedApproval rereads and validates the current manifest and
456+
// approval together. Callers use it after Verify when durable or transferred
457+
// state must bind the exact approval bytes that remain present.
458+
func ReadVerifiedApproval(repository, deliveryID, packageFingerprint string) (VerifiedApproval, error) {
459+
if !ValidSegment(deliveryID) || !ValidFingerprint(packageFingerprint) {
460+
return VerifiedApproval{}, fmt.Errorf("package path identity is invalid")
461+
}
462+
root, err := filepath.Abs(repository)
463+
if err != nil {
464+
return VerifiedApproval{}, err
465+
}
466+
info, err := os.Lstat(root)
467+
if err != nil || !info.IsDir() {
468+
return VerifiedApproval{}, fmt.Errorf("repository root is unavailable")
469+
}
470+
packageRoot := filepath.Join(root, ".boatstack", "work-packages", deliveryID, packageFingerprint)
471+
if info, err = os.Lstat(packageRoot); err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
472+
return VerifiedApproval{}, fmt.Errorf("package directory is unavailable or unsafe")
473+
}
474+
manifestRaw, err := readRegular(filepath.Join(packageRoot, "manifest.json"), maxPackageMetadataBytes)
475+
if err != nil {
476+
return VerifiedApproval{}, err
477+
}
478+
var manifest Manifest
479+
if err := StrictDecode(manifestRaw, &manifest); err != nil {
480+
return VerifiedApproval{}, fmt.Errorf("manifest: %w", err)
481+
}
482+
if !canonicalEncoding(manifestRaw, manifest) || !outputsSorted(manifest.Outputs) {
483+
return VerifiedApproval{}, fmt.Errorf("manifest encoding or output order is non-canonical")
484+
}
485+
manifestIdentity := manifest
486+
manifestIdentity.Fingerprint = ""
487+
identityRaw, err := Encode(manifestIdentity)
488+
if err != nil {
489+
return VerifiedApproval{}, err
490+
}
491+
if err := validateManifestIdentity(manifest, deliveryID, packageFingerprint, Digest(identityRaw)); err != nil {
492+
return VerifiedApproval{}, fmt.Errorf("manifest identity is invalid: %w", err)
493+
}
494+
approvalRaw, err := readRegular(filepath.Join(packageRoot, "approval.json"), maxPackageMetadataBytes)
495+
if err != nil {
496+
return VerifiedApproval{}, err
497+
}
498+
var approval Approval
499+
if err := StrictDecode(approvalRaw, &approval); err != nil {
500+
return VerifiedApproval{}, fmt.Errorf("approval: %w", err)
501+
}
502+
if err := ValidateApproval(approvalRaw, approval, manifest, deliveryID, packageFingerprint); err != nil {
503+
return VerifiedApproval{}, fmt.Errorf("approval identity is invalid: %w", err)
504+
}
505+
return VerifiedApproval{Manifest: manifest, Approval: approval, Raw: approvalRaw}, nil
506+
}
507+
446508
func validateManifestIdentity(manifest Manifest, deliveryID, packageFingerprint, identityFingerprint string) error {
447509
switch {
448510
case manifest.SchemaVersion != ManifestSchemaVersion:

boatstack/internal/softwaredelivery/effects/artifacts.go

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,7 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio
553553
}
554554

555555
var verifyWorkPackage = workpackage.Verify
556+
var prepareWorkspaceWorkPackageTransferFn = prepareWorkspaceWorkPackageTransfer
556557

557558
func capturePinnedWorkPackage(root *os.Root, deliveryID, fingerprint string) (string, func(), error) {
558559
repository, err := os.MkdirTemp("", "boatstack-work-package-snapshot-")
@@ -647,7 +648,7 @@ func prepareWorkspacePlanTransfer(repositoryRoot, workspacePath, deliveryID, exp
647648
mutations := []ports.ResourceMutation{planMutation, approvalMutation}
648649
var promotion planPromotionReceipt
649650
if decodeStrictArtifact(approvalRaw, &promotion) == nil && promotion.SchemaVersion == 2 {
650-
packageMutations, packageErr := prepareWorkspaceWorkPackageTransfer(repositoryRoot, workspacePath, deliveryID, promotion.WorkPackageFingerprint)
651+
packageMutations, packageErr := prepareWorkspaceWorkPackageTransferFn(repositoryRoot, workspacePath, deliveryID, promotion.WorkPackageFingerprint, promotion.WorkPackageApprovalFingerprint)
651652
if packageErr != nil {
652653
return nil, packageErr
653654
}
@@ -656,7 +657,7 @@ func prepareWorkspacePlanTransfer(repositoryRoot, workspacePath, deliveryID, exp
656657
return mutations, nil
657658
}
658659

659-
func prepareWorkspaceWorkPackageTransfer(repositoryRoot, workspacePath, deliveryID, packageFingerprint string) ([]ports.ResourceMutation, error) {
660+
func prepareWorkspaceWorkPackageTransfer(repositoryRoot, workspacePath, deliveryID, packageFingerprint, expectedApprovalFingerprint string) ([]ports.ResourceMutation, error) {
660661
sourcePath := filepath.Join(repositoryRoot, ".boatstack", "work-packages", deliveryID, packageFingerprint)
661662
root, err := os.OpenRoot(sourcePath)
662663
if err != nil {
@@ -680,6 +681,10 @@ func prepareWorkspaceWorkPackageTransfer(repositoryRoot, workspacePath, delivery
680681
if verified.Integrity != workpackage.Valid || verified.Contract != workpackage.Valid || verified.Approval != workpackage.Valid {
681682
return nil, fmt.Errorf("workspace work package verification failed: %s", strings.Join(verified.Diagnostics, "; "))
682683
}
684+
verifiedApproval, err := workpackage.ReadVerifiedApproval(snapshotRepository, deliveryID, packageFingerprint)
685+
if err != nil || verifiedApproval.Approval.Fingerprint != expectedApprovalFingerprint {
686+
return nil, fmt.Errorf("workspace work package approval does not bind promotion lineage")
687+
}
683688
snapshotRoot := filepath.Join(snapshotRepository, ".boatstack", "work-packages", deliveryID, packageFingerprint)
684689
destinationRoot := filepath.Join(workspacePath, ".boatstack", "work-packages", deliveryID, packageFingerprint)
685690
destinationExists := false
@@ -707,6 +712,12 @@ func prepareWorkspaceWorkPackageTransfer(repositoryRoot, workspacePath, delivery
707712
if err != nil {
708713
return err
709714
}
715+
if filepath.ToSlash(relative) == "approval.json" {
716+
if !bytes.Equal(raw, verifiedApproval.Raw) {
717+
return fmt.Errorf("workspace work package approval changed after verification")
718+
}
719+
raw = verifiedApproval.Raw
720+
}
710721
mutation, err := immutableWorkPackageMutation(filepath.Join(destinationRoot, relative), raw)
711722
if err != nil {
712723
return err
@@ -754,18 +765,11 @@ func validateWorkspaceApproval(repositoryRoot, deliveryID, expectedPlanFingerpri
754765
if verified.Integrity != workpackage.Valid || verified.Contract != workpackage.Valid || verified.Approval != workpackage.Valid {
755766
return fmt.Errorf("schema-2 promotion package is invalid")
756767
}
757-
packageRoot := filepath.Join(repositoryRoot, ".boatstack", "work-packages", deliveryID, promotion.WorkPackageFingerprint)
758-
approvalRaw, err := os.ReadFile(filepath.Join(packageRoot, "approval.json"))
759-
var approval workpackage.Approval
760-
if err != nil || workpackage.StrictDecode(approvalRaw, &approval) != nil || approval.Fingerprint != promotion.WorkPackageApprovalFingerprint {
768+
verifiedApproval, err := workpackage.ReadVerifiedApproval(repositoryRoot, deliveryID, promotion.WorkPackageFingerprint)
769+
if err != nil || verifiedApproval.Approval.Fingerprint != promotion.WorkPackageApprovalFingerprint {
761770
return fmt.Errorf("schema-2 promotion approval lineage is invalid")
762771
}
763-
manifestRaw, err := os.ReadFile(filepath.Join(packageRoot, "manifest.json"))
764-
var manifest workpackage.Manifest
765-
if err != nil || workpackage.StrictDecode(manifestRaw, &manifest) != nil {
766-
return fmt.Errorf("schema-2 promotion manifest is invalid")
767-
}
768-
for _, output := range manifest.Outputs {
772+
for _, output := range verifiedApproval.Manifest.Outputs {
769773
if output.ID == promotion.PlanOutputID && output.Required && output.SHA256 == expectedPlanFingerprint {
770774
return nil
771775
}

boatstack/internal/softwaredelivery/effects/work_package_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,22 @@ func TestWorkPackageAdmitApprovePromoteUsesExactGenericSnapshot(t *testing.T) {
214214
t.Fatalf("promoted plan=%q", current)
215215
}
216216
workspace := t.TempDir()
217+
originalTransfer := prepareWorkspaceWorkPackageTransferFn
218+
t.Cleanup(func() { prepareWorkspaceWorkPackageTransferFn = originalTransfer })
219+
prepareWorkspaceWorkPackageTransferFn = func(repositoryRoot, workspacePath, deliveryID, packageFingerprint, expectedApprovalFingerprint string) ([]ports.ResourceMutation, error) {
220+
if err := os.WriteFile(filepath.Join(packageRoot, "approval.json"), substitutedApprovalRaw, 0o644); err != nil {
221+
t.Fatal(err)
222+
}
223+
return originalTransfer(repositoryRoot, workspacePath, deliveryID, packageFingerprint, expectedApprovalFingerprint)
224+
}
225+
_, transferSubstitutionErr := prepareWorkspacePlanTransfer(repository, workspace, "delivery", state.PlanFingerprint, state.ApprovalFingerprint)
226+
prepareWorkspaceWorkPackageTransferFn = originalTransfer
227+
if err := os.WriteFile(filepath.Join(packageRoot, "approval.json"), originalApproval, 0o644); err != nil {
228+
t.Fatal(err)
229+
}
230+
if transferSubstitutionErr == nil || !strings.Contains(transferSubstitutionErr.Error(), "promotion lineage") {
231+
t.Fatalf("workspace transfer accepted substituted package approval: %v", transferSubstitutionErr)
232+
}
217233
transfers, err := prepareWorkspacePlanTransfer(repository, workspace, "delivery", state.PlanFingerprint, state.ApprovalFingerprint)
218234
if err != nil {
219235
t.Fatal(err)

boatstack/internal/softwaredelivery/plant/observer.go

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -702,28 +702,20 @@ func observeWorkPackage(layout ports.ControllerLayout, state durable.State, now
702702
if err != nil || !exists {
703703
return evidence, false, err
704704
}
705-
manifestRaw, err := os.ReadFile(manifestPath)
706-
if err != nil {
707-
return evidence, false, err
708-
}
709-
var manifest workpackage.Manifest
710-
if workpackage.StrictDecode(manifestRaw, &manifest) != nil {
711-
return evidence, false, nil
712-
}
713-
result := workpackage.Verify(layout.RepositoryRoot, state.Objective.DeliveryID, state.WorkPackageFingerprint, nil)
705+
result := verifyObservedWorkPackage(layout.RepositoryRoot, state.Objective.DeliveryID, state.WorkPackageFingerprint, nil)
714706
valid := result.Integrity == workpackage.Valid && result.Contract == workpackage.Valid
715707
if state.WorkPackage == model.WorkPackageApproved {
716708
valid = valid && result.Approval == workpackage.Valid
717709
if valid {
718-
raw, _ := os.ReadFile(filepath.Join(root, "approval.json"))
719-
var approval workpackage.Approval
720-
_ = workpackage.StrictDecode(raw, &approval)
721-
valid = approval.Fingerprint == state.WorkPackageApprovalFingerprint
710+
verified, approvalErr := workpackage.ReadVerifiedApproval(layout.RepositoryRoot, state.Objective.DeliveryID, state.WorkPackageFingerprint)
711+
valid = approvalErr == nil && verified.Approval.Fingerprint == state.WorkPackageApprovalFingerprint
722712
}
723713
}
724714
return evidence, valid, nil
725715
}
726716

717+
var verifyObservedWorkPackage = workpackage.Verify
718+
727719
type pendingJournalHeader struct {
728720
SchemaVersion int `json:"schema_version"`
729721
TransitionID string `json:"transition_id"`

boatstack/internal/softwaredelivery/plant/observer_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,35 @@ func TestObserverValidatesAdmittedWorkPackageWithoutPrematurePlanPromotion(t *te
6666
if _, err := os.Stat(filepath.Join(repository, ".boatstack", "plans", deliveryID+".source")); !os.IsNotExist(err) {
6767
t.Fatalf("admission prematurely created a canonical plan: %v", err)
6868
}
69+
approval, approvalRaw, err := workpackage.SealApproval(workpackage.Approval{
70+
DeliveryID: deliveryID, PackageFingerprint: manifest.Fingerprint, ManifestFingerprint: manifest.Fingerprint,
71+
AdmissionID: "adm-approve", Actor: "reviewer", IdentityRole: "developer", IdentityProviderFingerprint: strings.Repeat("9", 64), ApprovedAt: time.Unix(99, 0).UTC(),
72+
AuthoritySources: []workpackage.AuthoritySource{{ID: "human", Class: "human", Subject: "reviewer", Fingerprint: "authority-proof"}},
73+
})
74+
if err != nil {
75+
t.Fatal(err)
76+
}
77+
if err := os.WriteFile(filepath.Join(root, "approval.json"), approvalRaw, 0o644); err != nil {
78+
t.Fatal(err)
79+
}
80+
state.WorkPackage = model.WorkPackageApproved
81+
state.WorkPackageApprovalFingerprint = approval.Fingerprint
82+
originalVerify := verifyObservedWorkPackage
83+
t.Cleanup(func() { verifyObservedWorkPackage = originalVerify })
84+
verifyObservedWorkPackage = func(repository, deliveryID, packageFingerprint string, current *workpackage.CurrentProgram) workpackage.Result {
85+
result := originalVerify(repository, deliveryID, packageFingerprint, current)
86+
if err := os.WriteFile(filepath.Join(root, "approval.json"), append(append([]byte(nil), approvalRaw...), '\n'), 0o644); err != nil {
87+
t.Fatal(err)
88+
}
89+
return result
90+
}
91+
if _, valid, err := observeWorkPackage(ports.ControllerLayout{RepositoryRoot: repository}, state, time.Unix(100, 0).UTC()); err != nil || valid {
92+
t.Fatalf("post-verification approval substitution valid=%t err=%v", valid, err)
93+
}
94+
verifyObservedWorkPackage = originalVerify
95+
if err := os.WriteFile(filepath.Join(root, "approval.json"), approvalRaw, 0o644); err != nil {
96+
t.Fatal(err)
97+
}
6998
if err := os.WriteFile(filepath.Join(root, "plan.md"), []byte("tampered"), 0o644); err != nil {
7099
t.Fatal(err)
71100
}

0 commit comments

Comments
 (0)