diff --git a/app/controlplane/api/controlplane/v1/attestation_state.proto b/app/controlplane/api/controlplane/v1/attestation_state.proto index d6df7d428..7816d3a72 100644 --- a/app/controlplane/api/controlplane/v1/attestation_state.proto +++ b/app/controlplane/api/controlplane/v1/attestation_state.proto @@ -53,7 +53,10 @@ message AttestationStateServiceSaveRequest { string base_digest = 3; } -message AttestationStateServiceSaveResponse {} +message AttestationStateServiceSaveResponse { + // digest of the newly saved attestation state + string digest = 1; +} message AttestationStateServiceReadRequest { string workflow_run_id = 1 [(buf.validate.field).string = {min_len: 1}]; diff --git a/app/controlplane/internal/service/attestationstate.go b/app/controlplane/internal/service/attestationstate.go index 4ff75cb51..1a623e006 100644 --- a/app/controlplane/internal/service/attestationstate.go +++ b/app/controlplane/internal/service/attestationstate.go @@ -105,7 +105,7 @@ func (s *AttestationStateService) Save(ctx context.Context, req *cpAPI.Attestati return nil, errors.Forbidden("forbidden", "failed to authenticate request") } - err = s.attestationStateUseCase.Save(ctx, wf.ID.String(), req.WorkflowRunId, req.AttestationState, encryptionPassphrase, biz.WithAttStateBaseDigest(req.GetBaseDigest())) + digest, err := s.attestationStateUseCase.Save(ctx, wf.ID.String(), req.WorkflowRunId, req.AttestationState, encryptionPassphrase, biz.WithAttStateBaseDigest(req.GetBaseDigest())) if err != nil { if biz.IsErrAttestationStateConflict(err) { return nil, cpAPI.ErrorAttestationStateErrorConflict("saving attestation: %s", err.Error()) @@ -114,7 +114,7 @@ func (s *AttestationStateService) Save(ctx context.Context, req *cpAPI.Attestati return nil, handleUseCaseErr(err, s.log) } - return &cpAPI.AttestationStateServiceSaveResponse{}, nil + return &cpAPI.AttestationStateServiceSaveResponse{Digest: digest}, nil } func (s *AttestationStateService) Read(ctx context.Context, req *cpAPI.AttestationStateServiceReadRequest) (*cpAPI.AttestationStateServiceReadResponse, error) { diff --git a/app/controlplane/pkg/biz/attestationstate.go b/app/controlplane/pkg/biz/attestationstate.go index 074d53915..b9f79bc3e 100644 --- a/app/controlplane/pkg/biz/attestationstate.go +++ b/app/controlplane/pkg/biz/attestationstate.go @@ -39,7 +39,7 @@ type AttestationState struct { type AttestationStateRepo interface { Initialized(ctx context.Context, workflowRunID uuid.UUID) (bool, error) - Save(ctx context.Context, workflowRunID uuid.UUID, state []byte, baseDigest string) error + Save(ctx context.Context, workflowRunID uuid.UUID, state []byte, baseDigest string) (string, error) Read(ctx context.Context, workflowRunID uuid.UUID) ([]byte, string, error) Reset(ctx context.Context, workflowRunID uuid.UUID) error } @@ -79,7 +79,8 @@ func WithAttStateBaseDigest(digest string) SaveOption { } } -func (uc *AttestationStateUseCase) Save(ctx context.Context, workflowID, runID string, state *v1.CraftingState, passphrase string, opts ...SaveOption) error { +// Save persists the attestation state and returns the digest of the newly saved state. +func (uc *AttestationStateUseCase) Save(ctx context.Context, workflowID, runID string, state *v1.CraftingState, passphrase string, opts ...SaveOption) (string, error) { opt := &AttestationStateSaveOpts{} for _, o := range opts { @@ -88,28 +89,29 @@ func (uc *AttestationStateUseCase) Save(ctx context.Context, workflowID, runID s runUUID, err := uc.checkWorkflowRunInWorkflow(ctx, workflowID, runID) if err != nil { - return fmt.Errorf("failed to check workflow run: %w", err) + return "", fmt.Errorf("failed to check workflow run: %w", err) } rawState, err := proto.Marshal(state) if err != nil { - return fmt.Errorf("failed to marshal attestation state: %w", err) + return "", fmt.Errorf("failed to marshal attestation state: %w", err) } if passphrase == "" { - return NewErrValidationStr("passphrase is required") + return "", NewErrValidationStr("passphrase is required") } encryptedState, err := encrypt(rawState, passphrase) if err != nil { - return fmt.Errorf("failed to encrypt attestation state: %w", err) + return "", fmt.Errorf("failed to encrypt attestation state: %w", err) } - if err := uc.repo.Save(ctx, *runUUID, encryptedState, opt.BaseDigest); err != nil { - return fmt.Errorf("failed to save attestation state: %w", err) + digest, err := uc.repo.Save(ctx, *runUUID, encryptedState, opt.BaseDigest) + if err != nil { + return "", fmt.Errorf("failed to save attestation state: %w", err) } - return nil + return digest, nil } func (uc *AttestationStateUseCase) Read(ctx context.Context, workflowID, runID, passphrase string) (*AttestationState, error) { diff --git a/app/controlplane/pkg/biz/attestationstate_integration_test.go b/app/controlplane/pkg/biz/attestationstate_integration_test.go index e9a6c1e81..2940b4318 100644 --- a/app/controlplane/pkg/biz/attestationstate_integration_test.go +++ b/app/controlplane/pkg/biz/attestationstate_integration_test.go @@ -59,7 +59,7 @@ func (s *attestationStateTestSuite) TestInitialized() { }) s.T().Run("the run is initialized", func(t *testing.T) { - err := s.AttestationState.Save(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String(), s.testState, s.passphrase) + _, _, err := s.AttestationState.Save(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String(), s.testState, s.passphrase) s.NoError(err) ok, err := s.AttestationState.Initialized(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String()) s.NoError(err) @@ -70,19 +70,19 @@ func (s *attestationStateTestSuite) TestInitialized() { func (s *attestationStateTestSuite) TestSave() { ctx := context.Background() s.Run("run in different workflow causes error", func() { - err := s.AttestationState.Save(ctx, s.workflowOrg1.ID.String(), s.runOrg2.ID.String(), s.testState, s.passphrase) + _, err := s.AttestationState.Save(ctx, s.workflowOrg1.ID.String(), s.runOrg2.ID.String(), s.testState, s.passphrase) s.Error(err) s.True(biz.IsNotFound(err)) }) s.Run("the run doesn't exist", func() { - err := s.AttestationState.Save(ctx, s.workflowOrg1.ID.String(), uuid.NewString(), s.testState, s.passphrase) + _, err := s.AttestationState.Save(ctx, s.workflowOrg1.ID.String(), uuid.NewString(), s.testState, s.passphrase) s.Error(err) s.True(biz.IsNotFound(err)) }) s.Run("the run exists", func() { - err := s.AttestationState.Save(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String(), s.testState, s.passphrase) + _, err := s.AttestationState.Save(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String(), s.testState, s.passphrase) s.NoError(err) got, err := s.AttestationState.Read(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String(), s.passphrase) @@ -93,7 +93,7 @@ func (s *attestationStateTestSuite) TestSave() { }) s.Run("it can be overridden", func() { - err := s.AttestationState.Save(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String(), s.testState, s.passphrase) + _, err := s.AttestationState.Save(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String(), s.testState, s.passphrase) s.NoError(err) got, err := s.AttestationState.Read(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String(), s.passphrase) @@ -103,7 +103,7 @@ func (s *attestationStateTestSuite) TestSave() { } newState := &v1.CraftingState{} - err = s.AttestationState.Save(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String(), newState, s.passphrase) + _, err = s.AttestationState.Save(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String(), newState, s.passphrase) s.NoError(err) got, err = s.AttestationState.Read(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String(), s.passphrase) @@ -120,7 +120,7 @@ func (s *attestationStateTestSuite) TestSave() { // Write again passing the base digest update := &v1.CraftingState{Attestation: &v1.Attestation{Annotations: map[string]string{"key": "updated"}}} - err = s.AttestationState.Save(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String(), update, s.passphrase, biz.WithAttStateBaseDigest(state.Digest)) + _, err = s.AttestationState.Save(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String(), update, s.passphrase, biz.WithAttStateBaseDigest(state.Digest)) s.NoError(err) // The digest now should be different @@ -130,21 +130,47 @@ func (s *attestationStateTestSuite) TestSave() { s.NotEqual(state.Digest, updatedState.Digest) // trying to update passing the wrong base digest should fail with a conflict error - err = s.AttestationState.Save(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String(), update, s.passphrase, biz.WithAttStateBaseDigest(state.Digest)) + _, err = s.AttestationState.Save(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String(), update, s.passphrase, biz.WithAttStateBaseDigest(state.Digest)) s.Error(err) s.True(biz.IsErrAttestationStateConflict(err)) // but will not fail if base digest is not passed // trying to update passing the wrong base digest should fail with a conflict error - err = s.AttestationState.Save(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String(), update, s.passphrase, biz.WithAttStateBaseDigest("")) + _, err = s.AttestationState.Save(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String(), update, s.passphrase, biz.WithAttStateBaseDigest("")) s.NoError(err) }) + + s.Run("save returns digest that can be used for consecutive writes", func() { + // First save + firstDigest, err := s.AttestationState.Save(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String(), s.testState, s.passphrase) + s.NoError(err) + s.NotEmpty(firstDigest) + + // Second save using the returned digest as base_digest should succeed + update1 := &v1.CraftingState{Attestation: &v1.Attestation{Annotations: map[string]string{"key": "update1"}}} + secondDigest, err := s.AttestationState.Save(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String(), update1, s.passphrase, biz.WithAttStateBaseDigest(firstDigest)) + s.NoError(err) + s.NotEmpty(secondDigest) + s.NotEqual(firstDigest, secondDigest) + + // Third save using the second returned digest should also succeed + update2 := &v1.CraftingState{Attestation: &v1.Attestation{Annotations: map[string]string{"key": "update2"}}} + thirdDigest, err := s.AttestationState.Save(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String(), update2, s.passphrase, biz.WithAttStateBaseDigest(secondDigest)) + s.NoError(err) + s.NotEmpty(thirdDigest) + s.NotEqual(secondDigest, thirdDigest) + + // Verify the returned digest matches what Read returns + readState, err := s.AttestationState.Read(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String(), s.passphrase) + s.NoError(err) + s.Equal(thirdDigest, readState.Digest) + }) } func (s *attestationStateTestSuite) TestRead() { ctx := context.Background() s.T().Run("can be retrieved with same passphrase", func(t *testing.T) { - err := s.AttestationState.Save(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String(), s.testState, s.passphrase) + _, err := s.AttestationState.Save(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String(), s.testState, s.passphrase) s.NoError(err) got, err := s.AttestationState.Read(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String(), s.passphrase) @@ -155,7 +181,7 @@ func (s *attestationStateTestSuite) TestRead() { }) s.T().Run("it fails if they are different passphrases", func(t *testing.T) { - err := s.AttestationState.Save(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String(), s.testState, s.passphrase) + _, err := s.AttestationState.Save(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String(), s.testState, s.passphrase) s.NoError(err) got, err := s.AttestationState.Read(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String(), "wrong-passphrase") @@ -164,11 +190,11 @@ func (s *attestationStateTestSuite) TestRead() { }) s.T().Run("it fails if the content has been tampered with", func(t *testing.T) { - err := s.AttestationState.Save(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String(), s.testState, s.passphrase) + _, err := s.AttestationState.Save(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String(), s.testState, s.passphrase) s.NoError(err) // tamper directly with the database - err = s.Repos.AttestationState.Save(ctx, s.runOrg1.ID, []byte("tampered data modified directly in the DB"), "") + _, err = s.Repos.AttestationState.Save(ctx, s.runOrg1.ID, []byte("tampered data modified directly in the DB"), "") s.NoError(err) got, err := s.AttestationState.Read(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String(), s.passphrase) @@ -180,7 +206,7 @@ func (s *attestationStateTestSuite) TestRead() { func (s *attestationStateTestSuite) TestWorkflowRunLifecycle() { ctx := context.Background() s.T().Run("the state gets cleared when workflow run is set as finished", func(t *testing.T) { - err := s.AttestationState.Save(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String(), s.testState, s.passphrase) + _, err := s.AttestationState.Save(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String(), s.testState, s.passphrase) s.NoError(err) err = s.WorkflowRun.MarkAsFinished(ctx, s.runOrg1.ID.String(), biz.WorkflowRunSuccess, "finished") @@ -192,7 +218,7 @@ func (s *attestationStateTestSuite) TestWorkflowRunLifecycle() { }) s.T().Run("or it expires", func(t *testing.T) { - err := s.AttestationState.Save(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String(), s.testState, s.passphrase) + _, err := s.AttestationState.Save(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String(), s.testState, s.passphrase) s.NoError(err) err = s.Repos.WorkflowRunRepo.Expire(ctx, s.runOrg1.ID) @@ -208,7 +234,7 @@ func (s *attestationStateTestSuite) TestReset() { ctx := context.Background() s.T().Run("the run is initialized", func(t *testing.T) { - err := s.AttestationState.Save(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String(), s.testState, s.passphrase) + _, err := s.AttestationState.Save(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String(), s.testState, s.passphrase) s.NoError(err) ok, err := s.AttestationState.Initialized(ctx, s.workflowOrg1.ID.String(), s.runOrg1.ID.String()) diff --git a/app/controlplane/pkg/data/attestationstate.go b/app/controlplane/pkg/data/attestationstate.go index b667586eb..1cf948414 100644 --- a/app/controlplane/pkg/data/attestationstate.go +++ b/app/controlplane/pkg/data/attestationstate.go @@ -53,8 +53,14 @@ func (r *AttestationStateRepo) Initialized(ctx context.Context, runID uuid.UUID) // baseDigest, when provided will be used to check that it matches the digest of the state currently in the DB // if the digests do not match, the state has been modified and the caller should retry -func (r *AttestationStateRepo) Save(ctx context.Context, runID uuid.UUID, state []byte, baseDigest string) error { - return WithTx(ctx, r.data.DB, func(tx *ent.Tx) error { +// It returns the digest of the newly saved state. +func (r *AttestationStateRepo) Save(ctx context.Context, runID uuid.UUID, state []byte, baseDigest string) (string, error) { + newDigest, err := digest(state) + if err != nil { + return "", fmt.Errorf("failed to calculate digest of new state: %w", err) + } + + err = WithTx(ctx, r.data.DB, func(tx *ent.Tx) error { // compared the provided digest with the digest of the state in the DB // TODO: make digest check mandatory on updates if baseDigest != "" { @@ -86,6 +92,11 @@ func (r *AttestationStateRepo) Save(ctx context.Context, runID uuid.UUID, state } return nil }) + if err != nil { + return "", err + } + + return newDigest, nil } func (r *AttestationStateRepo) Read(ctx context.Context, runID uuid.UUID) ([]byte, string, error) { diff --git a/pkg/attestation/crafter/statemanager/remote/remote.go b/pkg/attestation/crafter/statemanager/remote/remote.go index bd0b2beca..bd0f8a023 100644 --- a/pkg/attestation/crafter/statemanager/remote/remote.go +++ b/pkg/attestation/crafter/statemanager/remote/remote.go @@ -67,10 +67,15 @@ func (r *Remote) Write(ctx context.Context, key string, state *crafter.Versioned } r.logger.Debug().Str("key", key).Str("baseDigest", state.UpdateCheckSum).Msg("Writing state to remote") - if _, err := r.client.Save(ctx, &pb.AttestationStateServiceSaveRequest{WorkflowRunId: key, AttestationState: state.CraftingState, BaseDigest: state.UpdateCheckSum}); err != nil { + resp, err := r.client.Save(ctx, &pb.AttestationStateServiceSaveRequest{WorkflowRunId: key, AttestationState: state.CraftingState, BaseDigest: state.UpdateCheckSum}) + if err != nil { return fmt.Errorf("failed to save state: %w", err) } + // Update the checksum with the digest of the newly saved state + // so subsequent writes use the correct base digest for OCC + state.UpdateCheckSum = resp.GetDigest() + return nil }