From ce346d02ea797d1e95e207bab4d687c55b765f5a Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Fri, 31 Jul 2026 09:13:05 +0200 Subject: [PATCH 1/2] fix(controlplane): emit an audit event when a project version is promoted to latest A project version becomes the latest one for its project in two ways: it is created as latest, or an already existing version is promoted. Only the first was reported. When an attestation promoted an existing version, the promote branch left the creation result unchanged and nothing was dispatched. Consumers that reconcile off these events never learned the pointer had moved, and kept following the previously latest version indefinitely, with no path to recovery since nothing re-checks afterwards. Report the promotion back from the repository and dispatch ProjectVersionUpdated carrying a new marked_as_latest field, both from the attestation path and from ProjectVersionUseCase.MarkAsLatest, which had no auditor at all. A promotion that does not move the latest pointer stays silent so the audit log is not filled with no-ops. The promotion contract, meaning the row-lock requirement, the released-version validation and whether the pointer actually moved, now lives in the single helper both paths already call rather than being recomputed at each call site. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 82261846-ded9-4e52-8707-d8a769402b5a --- app/controlplane/cmd/wire_gen.go | 2 +- .../pkg/auditor/events/project.go | 22 ++-- .../pkg/auditor/events/project_test.go | 31 +++++- .../project_version_marked_as_latest.json | 19 ++++ .../projects/project_version_updated.json | 5 +- .../project_version_updated_generic.json | 19 ++++ app/controlplane/pkg/biz/auditor.go | 14 ++- app/controlplane/pkg/biz/auditor_test.go | 82 ++++++++++++++ app/controlplane/pkg/biz/projectversion.go | 66 +++++++++++- .../pkg/biz/projectversion_audit_test.go | 80 ++++++++++++++ .../biz/projectversion_integration_test.go | 28 +++++ .../pkg/biz/testhelpers/wire_gen.go | 2 +- app/controlplane/pkg/biz/workflowrun.go | 15 ++- .../pkg/biz/workflowrun_audit_test.go | 100 ++++++++++++++++++ .../pkg/biz/workflowrun_integration_test.go | 54 ++++++++++ app/controlplane/pkg/data/projectversion.go | 75 ++++++++++--- app/controlplane/pkg/data/workflowrun.go | 21 ++-- 17 files changed, 589 insertions(+), 46 deletions(-) create mode 100644 app/controlplane/pkg/auditor/events/testdata/projects/project_version_marked_as_latest.json create mode 100644 app/controlplane/pkg/auditor/events/testdata/projects/project_version_updated_generic.json create mode 100644 app/controlplane/pkg/biz/auditor_test.go create mode 100644 app/controlplane/pkg/biz/projectversion_audit_test.go create mode 100644 app/controlplane/pkg/biz/workflowrun_audit_test.go diff --git a/app/controlplane/cmd/wire_gen.go b/app/controlplane/cmd/wire_gen.go index 350656274..4e2151e9e 100644 --- a/app/controlplane/cmd/wire_gen.go +++ b/app/controlplane/cmd/wire_gen.go @@ -224,7 +224,7 @@ func wireApp(contextContext context.Context, bootstrap *conf.Bootstrap, readerWr return nil, nil, err } projectVersionRepo := data.NewProjectVersionRepo(dataData, logger) - projectVersionUseCase := biz.NewProjectVersionUseCase(projectVersionRepo, logger) + projectVersionUseCase := biz.NewProjectVersionUseCase(projectVersionRepo, auditorUseCase, logger) policyevalbundleCache, err := policyevalbundle.New(contextContext, reloadableConnection, logger) if err != nil { cleanup3() diff --git a/app/controlplane/pkg/auditor/events/project.go b/app/controlplane/pkg/auditor/events/project.go index 01338a085..5ab5c0848 100644 --- a/app/controlplane/pkg/auditor/events/project.go +++ b/app/controlplane/pkg/auditor/events/project.go @@ -1,5 +1,5 @@ // -// Copyright 2025 The Chainloop Authors. +// Copyright 2025-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -164,6 +164,10 @@ type ProjectVersionUpdated struct { VersionID *uuid.UUID `json:"version_id,omitempty"` Version string `json:"version,omitempty"` NewVersion *string `json:"new_version,omitempty"` + // MarkedAsLatest reports that the update promoted this version to be the + // project's latest one. Always emitted, like Prerelease on the sibling + // events, so consumers can tell "not a promotion" from "older producer". + MarkedAsLatest bool `json:"marked_as_latest"` } func (p *ProjectVersionUpdated) ActionType() string { @@ -183,15 +187,17 @@ func (p *ProjectVersionUpdated) ActionInfo() (json.RawMessage, error) { } func (p *ProjectVersionUpdated) Description() string { - desc := fmt.Sprintf("%s has updated version '%s' for project '%s'", - auditor.GetActorIdentifier(), p.Version, p.ProjectName) - - if p.NewVersion != nil { - desc = fmt.Sprintf("%s has renamed version '%s' to '%s' for project '%s'", + switch { + case p.NewVersion != nil: + return fmt.Sprintf("%s has renamed version '%s' to '%s' for project '%s'", auditor.GetActorIdentifier(), p.Version, *p.NewVersion, p.ProjectName) + case p.MarkedAsLatest: + return fmt.Sprintf("%s has promoted version '%s' to latest for project '%s'", + auditor.GetActorIdentifier(), p.Version, p.ProjectName) + default: + return fmt.Sprintf("%s has updated version '%s' for project '%s'", + auditor.GetActorIdentifier(), p.Version, p.ProjectName) } - - return desc } // Helper function to make role names more user-friendly diff --git a/app/controlplane/pkg/auditor/events/project_test.go b/app/controlplane/pkg/auditor/events/project_test.go index 4527e4b1b..9bcf69f95 100644 --- a/app/controlplane/pkg/auditor/events/project_test.go +++ b/app/controlplane/pkg/auditor/events/project_test.go @@ -1,5 +1,5 @@ // -// Copyright 2025 The Chainloop Authors. +// Copyright 2025-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -93,6 +93,35 @@ func TestProjectEvents(t *testing.T) { actor: auditor.ActorTypeUser, actorID: userUUID, }, + { + name: "ProjectVersionUpdated without a specific change", + event: &events.ProjectVersionUpdated{ + ProjectBase: &events.ProjectBase{ + ProjectID: &projectUUID, + ProjectName: projectName, + }, + VersionID: &versionUUID, + Version: "v1.0.0", + }, + expected: "testdata/projects/project_version_updated_generic.json", + actor: auditor.ActorTypeUser, + actorID: userUUID, + }, + { + name: "ProjectVersionUpdated marked as latest", + event: &events.ProjectVersionUpdated{ + ProjectBase: &events.ProjectBase{ + ProjectID: &projectUUID, + ProjectName: projectName, + }, + VersionID: &versionUUID, + Version: "v1.0.0", + MarkedAsLatest: true, + }, + expected: "testdata/projects/project_version_marked_as_latest.json", + actor: auditor.ActorTypeUser, + actorID: userUUID, + }, { name: "ProjectMembershipAdded", event: &events.ProjectMembershipAdded{ diff --git a/app/controlplane/pkg/auditor/events/testdata/projects/project_version_marked_as_latest.json b/app/controlplane/pkg/auditor/events/testdata/projects/project_version_marked_as_latest.json new file mode 100644 index 000000000..242f1cede --- /dev/null +++ b/app/controlplane/pkg/auditor/events/testdata/projects/project_version_marked_as_latest.json @@ -0,0 +1,19 @@ +{ + "ActionType": "ProjectVersionUpdated", + "TargetType": "Project", + "TargetID": "3089bb36-e27b-428b-8009-d015c8737c56", + "ActorType": "USER", + "ActorID": "1089bb36-e27b-428b-8009-d015c8737c54", + "ActorEmail": "john@cyberdyne.io", + "ActorName": "John Connor", + "OrgID": "1089bb36-e27b-428b-8009-d015c8737c54", + "Description": "John Connor has promoted version 'v1.0.0' to latest for project 'test-project'", + "Info": { + "project_id": "3089bb36-e27b-428b-8009-d015c8737c56", + "project_name": "test-project", + "version_id": "5089bb36-e27b-428b-8009-d015c8737c58", + "version": "v1.0.0", + "marked_as_latest": true + }, + "Digest": "sha256:02de4db635507f73918e0a5ad07039e3da666d323ab2de72bd106160a2bc7dca" +} \ No newline at end of file diff --git a/app/controlplane/pkg/auditor/events/testdata/projects/project_version_updated.json b/app/controlplane/pkg/auditor/events/testdata/projects/project_version_updated.json index e0d5bb454..c973bbec9 100644 --- a/app/controlplane/pkg/auditor/events/testdata/projects/project_version_updated.json +++ b/app/controlplane/pkg/auditor/events/testdata/projects/project_version_updated.json @@ -13,7 +13,8 @@ "project_name": "test-project", "version_id": "5089bb36-e27b-428b-8009-d015c8737c58", "version": "v1.0.0", - "new_version": "v1.0.1" + "new_version": "v1.0.1", + "marked_as_latest": false }, - "Digest": "sha256:f86470cccd6d88b274433350b0ff3958b9f081a12bffcbcd20b6648d28182c1f" + "Digest": "sha256:3d2d27245dbe6e2629eb88259c56194e0a0c81820fa96e3391b316a511fe18c1" } \ No newline at end of file diff --git a/app/controlplane/pkg/auditor/events/testdata/projects/project_version_updated_generic.json b/app/controlplane/pkg/auditor/events/testdata/projects/project_version_updated_generic.json new file mode 100644 index 000000000..44cf7ea90 --- /dev/null +++ b/app/controlplane/pkg/auditor/events/testdata/projects/project_version_updated_generic.json @@ -0,0 +1,19 @@ +{ + "ActionType": "ProjectVersionUpdated", + "TargetType": "Project", + "TargetID": "3089bb36-e27b-428b-8009-d015c8737c56", + "ActorType": "USER", + "ActorID": "1089bb36-e27b-428b-8009-d015c8737c54", + "ActorEmail": "john@cyberdyne.io", + "ActorName": "John Connor", + "OrgID": "1089bb36-e27b-428b-8009-d015c8737c54", + "Description": "John Connor has updated version 'v1.0.0' for project 'test-project'", + "Info": { + "project_id": "3089bb36-e27b-428b-8009-d015c8737c56", + "project_name": "test-project", + "version_id": "5089bb36-e27b-428b-8009-d015c8737c58", + "version": "v1.0.0", + "marked_as_latest": false + }, + "Digest": "sha256:2d922b6b3e952d19a82db0af8d72d8a12eed1afdf19d7eddd3be7c069b241597" +} \ No newline at end of file diff --git a/app/controlplane/pkg/biz/auditor.go b/app/controlplane/pkg/biz/auditor.go index 60f254ae8..831c3f7ca 100644 --- a/app/controlplane/pkg/biz/auditor.go +++ b/app/controlplane/pkg/biz/auditor.go @@ -34,17 +34,25 @@ type AuditorUseCase struct { dispatcher *auditor.Dispatcher } +// NewAuditorUseCase builds an AuditorUseCase from the NATS-backed publisher. +// It takes the concrete type because the publisher is nil when auditing is +// disabled, and a nil pointer assigned straight to an interface would leave the +// dispatcher holding a typed-nil that reports itself as enabled. func NewAuditorUseCase(p *auditor.AuditLogPublisher, logger log.Logger) *AuditorUseCase { - // keep the Publisher interface nil when the publisher is disabled so the - // dispatcher short-circuits instead of holding a typed-nil interface var publisher auditor.Publisher if p != nil { publisher = p } + return newAuditorUseCase(publisher, logger) +} + +// newAuditorUseCase builds an AuditorUseCase over any publisher. A nil +// publisher makes dispatching a no-op. +func newAuditorUseCase(p auditor.Publisher, logger log.Logger) *AuditorUseCase { return &AuditorUseCase{ log: log.NewHelper(log.With(logger, "component", "biz/auditor")), - dispatcher: auditor.NewDispatcher(publisher, logger), + dispatcher: auditor.NewDispatcher(p, logger), } } diff --git a/app/controlplane/pkg/biz/auditor_test.go b/app/controlplane/pkg/biz/auditor_test.go new file mode 100644 index 000000000..3590c40bc --- /dev/null +++ b/app/controlplane/pkg/biz/auditor_test.go @@ -0,0 +1,82 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package biz + +import ( + "context" + "encoding/json" + "io" + "testing" + + "github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext/entities" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/auditor" + "github.com/go-kratos/kratos/v2/log" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// recordingPublisher captures the audit events a use case dispatches so tests +// can assert on them. The production publisher is NATS-backed, so this is the +// only way to observe dispatches without a broker. +type recordingPublisher struct { + published []*auditor.EventPayload +} + +func (p *recordingPublisher) Publish(data *auditor.EventPayload) error { + p.published = append(p.published, data) + return nil +} + +// assertSingleProjectVersionEvent checks that exactly one event was recorded, +// that it reports the given action for the given project, and that its payload +// describes the given version. wantMarkedAsLatest pins whether the event +// announces a promotion. +func (p *recordingPublisher) assertSingleProjectVersionEvent( + t *testing.T, wantAction string, project *Project, version *ProjectVersion, wantMarkedAsLatest bool, +) { + t.Helper() + + require.Len(t, p.published, 1) + got := p.published[0].Data + assert.Equal(t, wantAction, got.ActionType) + assert.Equal(t, &project.ID, got.TargetID) + assert.Equal(t, &project.OrgID, got.OrgID) + + var info struct { + VersionID *uuid.UUID `json:"version_id"` + Version string `json:"version"` + MarkedAsLatest bool `json:"marked_as_latest"` + } + require.NoError(t, json.Unmarshal(got.Info, &info)) + assert.Equal(t, &version.ID, info.VersionID) + assert.Equal(t, version.Version, info.Version) + assert.Equal(t, wantMarkedAsLatest, info.MarkedAsLatest) +} + +// newRecordingAuditor builds an AuditorUseCase backed by a recordingPublisher, +// through the same constructor production uses so the two cannot drift. +func newRecordingAuditor() (*AuditorUseCase, *recordingPublisher) { + publisher := &recordingPublisher{} + + return newAuditorUseCase(publisher, log.NewStdLogger(io.Discard)), publisher +} + +// ctxWithAPITokenActor returns a context carrying an actor, required by the +// audit entries that report on project resources. +func ctxWithAPITokenActor(ctx context.Context) context.Context { + return entities.WithCurrentAPIToken(ctx, &entities.APIToken{ID: uuid.NewString(), Name: "test-token"}) +} diff --git a/app/controlplane/pkg/biz/projectversion.go b/app/controlplane/pkg/biz/projectversion.go index 21b3e90ec..a27d21516 100644 --- a/app/controlplane/pkg/biz/projectversion.go +++ b/app/controlplane/pkg/biz/projectversion.go @@ -20,6 +20,7 @@ import ( "io" "time" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/auditor/events" "github.com/chainloop-dev/chainloop/pkg/otelx" "github.com/chainloop-dev/chainloop/pkg/servicelogger" "github.com/go-kratos/kratos/v2/log" @@ -51,24 +52,72 @@ type ProjectVersion struct { ProjectID uuid.UUID } +// ProjectVersionPromotion is the outcome of promoting a project version to be +// the latest one. +type ProjectVersionPromotion struct { + // Promoted reports whether the promotion changed which version is the + // latest one. It is false when the version already was the latest. + Promoted bool + // Version and Project describe the promotion for auditing purposes. Both are + // populated whenever the promotion succeeded, so that a committed promotion + // can always be reported. + Version *ProjectVersion + Project *Project +} + +// dispatchProjectVersionPromoted reports that a project version became the +// latest one for its project. Every path that promotes a version funnels +// through here: downstream consumers reconcile off this event, so a promotion +// must be as loud as a creation, otherwise anything tracking the latest version +// of the project silently falls behind. +// +// A promotion deliberately reuses ProjectVersionUpdated rather than declaring +// its own action type, and that choice is load-bearing. Subjects are published +// as "audit..", and consumers subscribe to a fixed +// list of them, so a new action type lands on a subject nobody is listening to +// and every promotion is dropped — the very failure this event exists to +// prevent, but silent. If this action type ever changes, the consumers must +// subscribe to the new subject and be released FIRST; MarkedAsLatest is what +// lets a consumer tell a promotion from a rename in the meantime. +func dispatchProjectVersionPromoted(ctx context.Context, auditorUC *AuditorUseCase, project *Project, version *ProjectVersion) { + if auditorUC == nil || project == nil || version == nil { + return + } + + auditorUC.Dispatch(ctx, &events.ProjectVersionUpdated{ + ProjectBase: &events.ProjectBase{ + ProjectID: &project.ID, + ProjectName: project.Name, + }, + VersionID: &version.ID, + Version: version.Version, + MarkedAsLatest: true, + }, &project.OrgID) +} + type ProjectVersionRepo interface { FindByProjectAndVersion(ctx context.Context, projectID uuid.UUID, version string) (*ProjectVersion, error) Update(ctx context.Context, versionID uuid.UUID, updates *ProjectVersionUpdateOpts) (*ProjectVersion, error) Create(ctx context.Context, projectID uuid.UUID, version string, prerelease bool) (*ProjectVersion, error) - MarkAsLatest(ctx context.Context, projectID, versionID uuid.UUID) error + MarkAsLatest(ctx context.Context, projectID, versionID uuid.UUID) (*ProjectVersionPromotion, error) } type ProjectVersionUseCase struct { projectRepo ProjectVersionRepo + auditorUC *AuditorUseCase logger *log.Helper } -func NewProjectVersionUseCase(repo ProjectVersionRepo, l log.Logger) *ProjectVersionUseCase { +func NewProjectVersionUseCase(repo ProjectVersionRepo, auditorUC *AuditorUseCase, l log.Logger) *ProjectVersionUseCase { if l == nil { l = log.NewStdLogger(io.Discard) } - return &ProjectVersionUseCase{projectRepo: repo, logger: servicelogger.ScopedHelper(l, "biz/project-version")} + return &ProjectVersionUseCase{ + projectRepo: repo, + auditorUC: auditorUC, + logger: servicelogger.ScopedHelper(l, "biz/project-version"), + } } func (uc *ProjectVersionUseCase) FindByProjectAndVersion(ctx context.Context, projectID string, version string) (*ProjectVersion, error) { @@ -116,7 +165,16 @@ func (uc *ProjectVersionUseCase) MarkAsLatest(ctx context.Context, projectID, ve return NewErrInvalidUUID(err) } - return uc.projectRepo.MarkAsLatest(ctx, projectUUID, versionUUID) + promotion, err := uc.projectRepo.MarkAsLatest(ctx, projectUUID, versionUUID) + if err != nil { + return err + } + + if promotion.Promoted { + dispatchProjectVersionPromoted(ctx, uc.auditorUC, promotion.Project, promotion.Version) + } + + return nil } func (uc *ProjectVersionUseCase) Create(ctx context.Context, projectID, version string, prerelease bool) (*ProjectVersion, error) { diff --git a/app/controlplane/pkg/biz/projectversion_audit_test.go b/app/controlplane/pkg/biz/projectversion_audit_test.go new file mode 100644 index 000000000..a3ceb8a35 --- /dev/null +++ b/app/controlplane/pkg/biz/projectversion_audit_test.go @@ -0,0 +1,80 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package biz + +import ( + "context" + "testing" + + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/auditor/events" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +// stubProjectVersionRepo returns a canned promotion outcome. Only MarkAsLatest +// is exercised; any other call panics on the nil embedded interface. +type stubProjectVersionRepo struct { + ProjectVersionRepo + promotion *ProjectVersionPromotion +} + +func (s *stubProjectVersionRepo) MarkAsLatest(_ context.Context, _, _ uuid.UUID) (*ProjectVersionPromotion, error) { + return s.promotion, nil +} + +// Promoting a version out of band must be as loud as promoting it through an +// attestation, so that whatever tracks the latest version of a project follows. +func TestMarkAsLatestDispatchesProjectVersionUpdated(t *testing.T) { + project := &Project{ID: uuid.New(), Name: "test-project", OrgID: uuid.New()} + version := &ProjectVersion{ID: uuid.New(), Version: "v1.0.0", Prerelease: true, Latest: true} + + testCases := []struct { + name string + promoted bool + wantAction string // empty means nothing should be dispatched + }{ + { + name: "version promoted", + promoted: true, + wantAction: events.ProjectVersionUpdatedActionType, + }, + { + name: "version already was the latest", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + auditorUC, publisher := newRecordingAuditor() + repo := &stubProjectVersionRepo{promotion: &ProjectVersionPromotion{ + Promoted: tc.promoted, + Version: version, + Project: project, + }} + + uc := NewProjectVersionUseCase(repo, auditorUC, nil) + err := uc.MarkAsLatest(ctxWithAPITokenActor(context.Background()), project.ID.String(), version.ID.String()) + require.NoError(t, err) + + if tc.wantAction == "" { + require.Empty(t, publisher.published) + return + } + + publisher.assertSingleProjectVersionEvent(t, tc.wantAction, project, version, true) + }) + } +} diff --git a/app/controlplane/pkg/biz/projectversion_integration_test.go b/app/controlplane/pkg/biz/projectversion_integration_test.go index 6a9fcec77..8910d7742 100644 --- a/app/controlplane/pkg/biz/projectversion_integration_test.go +++ b/app/controlplane/pkg/biz/projectversion_integration_test.go @@ -22,6 +22,7 @@ import ( "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz/testhelpers" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" @@ -146,6 +147,33 @@ func (s *ProjectVersionIntegrationTestSuite) TestMarkAsLatest() { require.False(t, v2Demoted.Latest) } +// The repository is what knows whether the promotion actually moved the latest +// pointer, so it reports it back for the use case to audit. +func (s *ProjectVersionIntegrationTestSuite) TestMarkAsLatestReportsPromotion() { + t := s.T() + ctx := context.Background() + repo := data.NewProjectVersionRepo(s.Data, s.L) + + v1, err := s.ProjectVersion.Create(ctx, s.project.ID.String(), "1.0.0", true) + require.NoError(t, err) + _, err = s.ProjectVersion.Create(ctx, s.project.ID.String(), "2.0.0", true) + require.NoError(t, err) + + promotion, err := repo.MarkAsLatest(ctx, s.project.ID, v1.ID) + require.NoError(t, err) + require.True(t, promotion.Promoted) + require.Equal(t, v1.ID, promotion.Version.ID) + require.Equal(t, "1.0.0", promotion.Version.Version) + require.True(t, promotion.Version.Latest) + require.Equal(t, s.project.ID, promotion.Project.ID) + require.Equal(t, s.org.ID, promotion.Project.OrgID.String()) + + // Promoting the version that already is the latest changes nothing + promotion, err = repo.MarkAsLatest(ctx, s.project.ID, v1.ID) + require.NoError(t, err) + require.False(t, promotion.Promoted) +} + func (s *ProjectVersionIntegrationTestSuite) TestMarkAsLatestReleasedVersionError() { t := s.T() ctx := context.Background() diff --git a/app/controlplane/pkg/biz/testhelpers/wire_gen.go b/app/controlplane/pkg/biz/testhelpers/wire_gen.go index 187fea5ee..7b0d68699 100644 --- a/app/controlplane/pkg/biz/testhelpers/wire_gen.go +++ b/app/controlplane/pkg/biz/testhelpers/wire_gen.go @@ -166,7 +166,7 @@ func WireTestData(contextContext context.Context, testDatabase *TestDatabase, t return nil, nil, err } projectVersionRepo := data.NewProjectVersionRepo(dataData, logger) - projectVersionUseCase := biz.NewProjectVersionUseCase(projectVersionRepo, logger) + projectVersionUseCase := biz.NewProjectVersionUseCase(projectVersionRepo, auditorUseCase, logger) groupUseCase := biz.NewGroupUseCase(logger, groupRepo, membershipRepo, userRepo, orgInvitationUseCase, auditorUseCase, orgInvitationRepo, authzUseCase, membershipUseCase) projectUseCase := biz.NewProjectsUseCase(logger, projectsRepo, membershipRepo, auditorUseCase, groupUseCase, membershipUseCase, orgInvitationUseCase, orgInvitationRepo, authzUseCase) testingRepos := &TestingRepos{ diff --git a/app/controlplane/pkg/biz/workflowrun.go b/app/controlplane/pkg/biz/workflowrun.go index 86fcde717..8b6dea686 100644 --- a/app/controlplane/pkg/biz/workflowrun.go +++ b/app/controlplane/pkg/biz/workflowrun.go @@ -88,9 +88,13 @@ const ( ) type WorkflowRunRepoCreateResult struct { - Run *WorkflowRun - Project *Project + Run *WorkflowRun + Project *Project + // VersionCreated reports that the run created the project version. VersionCreated bool + // VersionPromoted reports that the run promoted an already existing project + // version to be the latest one. Mutually exclusive with VersionCreated. + VersionPromoted bool } type WorkflowRunRepo interface { @@ -323,8 +327,9 @@ func (uc *WorkflowRunUseCase) Create(ctx context.Context, opts *WorkflowRunCreat return nil, err } - // Dispatch audit event for project version creation if a new version was created - if result.VersionCreated && uc.auditorUC != nil && result.Project != nil { + // Report the project version transition this run caused, if any. + switch { + case result.VersionCreated && uc.auditorUC != nil && result.Project != nil: uc.auditorUC.Dispatch(ctx, &events.ProjectVersionCreated{ ProjectBase: &events.ProjectBase{ ProjectID: &result.Project.ID, @@ -334,6 +339,8 @@ func (uc *WorkflowRunUseCase) Create(ctx context.Context, opts *WorkflowRunCreat Version: result.Run.ProjectVersion.Version, Prerelease: result.Run.ProjectVersion.Prerelease, }, &result.Project.OrgID) + case result.VersionPromoted: + dispatchProjectVersionPromoted(ctx, uc.auditorUC, result.Project, result.Run.ProjectVersion) } return result.Run, nil diff --git a/app/controlplane/pkg/biz/workflowrun_audit_test.go b/app/controlplane/pkg/biz/workflowrun_audit_test.go new file mode 100644 index 000000000..d581aef12 --- /dev/null +++ b/app/controlplane/pkg/biz/workflowrun_audit_test.go @@ -0,0 +1,100 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package biz + +import ( + "context" + "testing" + + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/auditor/events" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +// stubWorkflowRunRepo returns a canned creation result. Only Create is +// exercised; any other call panics on the nil embedded interface. +type stubWorkflowRunRepo struct { + WorkflowRunRepo + result *WorkflowRunRepoCreateResult +} + +func (s *stubWorkflowRunRepo) Create(_ context.Context, _ *WorkflowRunRepoCreateOpts) (*WorkflowRunRepoCreateResult, error) { + return s.result, nil +} + +// A project version only reaches "latest" through a creation or a promotion, +// and the products that track the latest version of a project reconcile off +// these audit events, so both transitions must be reported. +func TestWorkflowRunCreateDispatchesProjectVersionEvents(t *testing.T) { + project := &Project{ID: uuid.New(), Name: "test-project", OrgID: uuid.New()} + version := &ProjectVersion{ID: uuid.New(), Version: "v1.0.0", Prerelease: true, Latest: true} + + testCases := []struct { + name string + versionCreated bool + versionPromoted bool + wantAction string // empty means nothing should be dispatched + wantMarkedAsLatest bool + }{ + { + name: "version created", + versionCreated: true, + wantAction: events.ProjectVersionCreatedActionType, + }, + { + name: "existing version promoted to latest", + versionPromoted: true, + wantAction: events.ProjectVersionUpdatedActionType, + wantMarkedAsLatest: true, + }, + { + name: "version neither created nor promoted", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + auditorUC, publisher := newRecordingAuditor() + repo := &stubWorkflowRunRepo{result: &WorkflowRunRepoCreateResult{ + Project: project, + Run: &WorkflowRun{ID: uuid.New(), ProjectVersion: version}, + VersionCreated: tc.versionCreated, + VersionPromoted: tc.versionPromoted, + }} + + uc, err := NewWorkflowRunUseCase(&WorkflowRunUseCaseOpts{WfrRepo: repo, AuditorUC: auditorUC}) + require.NoError(t, err) + + _, err = uc.Create(ctxWithAPITokenActor(context.Background()), &WorkflowRunCreateOpts{ + WorkflowID: uuid.NewString(), + CASBackendID: uuid.New(), + ContractRevision: &WorkflowContractWithVersion{ + Contract: &WorkflowContract{LatestRevision: 1}, + Version: &WorkflowContractVersion{ID: uuid.New(), Revision: 1}, + }, + ProjectVersion: version.Version, + }) + require.NoError(t, err) + + if tc.wantAction == "" { + require.Empty(t, publisher.published) + return + } + + publisher.assertSingleProjectVersionEvent(t, tc.wantAction, project, version, tc.wantMarkedAsLatest) + }) + } +} diff --git a/app/controlplane/pkg/biz/workflowrun_integration_test.go b/app/controlplane/pkg/biz/workflowrun_integration_test.go index 4eb9199a3..36309e509 100644 --- a/app/controlplane/pkg/biz/workflowrun_integration_test.go +++ b/app/controlplane/pkg/biz/workflowrun_integration_test.go @@ -1018,6 +1018,60 @@ func (s *workflowRunIntegrationTestSuite) TestContractInformation() { }) } +// The repository is the only layer that can tell a version creation apart from +// the promotion of an existing one, so it must report the promotion back to the +// use case, which is what turns it into an audit event. +func (s *workflowRunIntegrationTestSuite) TestCreateReportsVersionPromotion() { + ctx := context.Background() + markTrue, markFalse := true, false + + createOpts := func(version string, markAsLatest *bool) *biz.WorkflowRunRepoCreateOpts { + return &biz.WorkflowRunRepoCreateOpts{ + WorkflowID: s.workflowOrg1.ID, + SchemaVersionID: s.contractVersion.Version.ID, + RunURL: "runURL", + RunnerType: "runnerType", + Backends: []uuid.UUID{s.casBackend.ID}, + LatestRevision: s.contractVersion.Contract.LatestRevision, + UsedRevision: s.contractVersion.Version.Revision, + ProjectVersion: version, + MarkAsLatest: markAsLatest, + } + } + + s.Run("creating a version is reported as a creation, not a promotion", func() { + result, err := s.Repos.WorkflowRunRepo.Create(ctx, createOpts("promotion-created", &markTrue)) + s.Require().NoError(err) + s.True(result.VersionCreated) + s.False(result.VersionPromoted) + s.True(result.Run.ProjectVersion.Latest) + }) + + s.Run("re-attesting the version that already is the latest is not a promotion", func() { + result, err := s.Repos.WorkflowRunRepo.Create(ctx, createOpts("promotion-noop", &markTrue)) + s.Require().NoError(err) + s.Require().True(result.Run.ProjectVersion.Latest) + + result, err = s.Repos.WorkflowRunRepo.Create(ctx, createOpts("promotion-noop", &markTrue)) + s.Require().NoError(err) + s.False(result.VersionCreated) + s.False(result.VersionPromoted) + }) + + s.Run("promoting an existing non-latest version is reported as a promotion", func() { + _, err := s.Repos.WorkflowRunRepo.Create(ctx, createOpts("promotion-old", &markFalse)) + s.Require().NoError(err) + _, err = s.Repos.WorkflowRunRepo.Create(ctx, createOpts("promotion-new", &markTrue)) + s.Require().NoError(err) + + result, err := s.Repos.WorkflowRunRepo.Create(ctx, createOpts("promotion-old", &markTrue)) + s.Require().NoError(err) + s.False(result.VersionCreated) + s.True(result.VersionPromoted) + s.True(result.Run.ProjectVersion.Latest) + }) +} + // Run the tests func TestWorkflowRunUseCase(t *testing.T) { suite.Run(t, new(workflowRunIntegrationTestSuite)) diff --git a/app/controlplane/pkg/data/projectversion.go b/app/controlplane/pkg/data/projectversion.go index 0e10ee0f5..dfc3b285d 100644 --- a/app/controlplane/pkg/data/projectversion.go +++ b/app/controlplane/pkg/data/projectversion.go @@ -17,6 +17,7 @@ package data import ( "context" + "fmt" "time" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" @@ -143,12 +144,27 @@ func createProjectVersionWithTx(ctx context.Context, tx *ent.Tx, projectID uuid. Save(ctx) } -func (r *ProjectVersionRepo) MarkAsLatest(ctx context.Context, projectID, versionID uuid.UUID) error { +func (r *ProjectVersionRepo) MarkAsLatest(ctx context.Context, projectID, versionID uuid.UUID) (*biz.ProjectVersionPromotion, error) { ctx, span := otelx.Start(ctx, projectVersionRepoTracer, "ProjectVersionRepo.MarkAsLatest") defer span.End() - return WithTx(ctx, r.data.DB, func(tx *ent.Tx) error { - v, err := tx.ProjectVersion.Query(). + // The audit context is read up front, before anything is written: the + // project's name and organization are stable, and reading them here keeps + // them off both the locked window and the post-commit path. A promotion that + // commits can then always be reported, whereas a read placed after the + // commit could fail and strand the promotion with no event — and the retry + // would see it as already latest and stay silent forever. + p, err := r.data.DB.Project.Get(ctx, projectID) + if err != nil { + if ent.IsNotFound(err) { + return nil, biz.NewErrNotFound("Project") + } + return nil, fmt.Errorf("loading project: %w", err) + } + + var promotion *biz.ProjectVersionPromotion + if err := WithTx(ctx, r.data.DB, func(tx *ent.Tx) error { + v, err := tx.ProjectVersion.Query().ForUpdate(). Where(projectversion.ID(versionID), projectversion.ProjectID(projectID), projectversion.DeletedAtIsNil()). Only(ctx) if err != nil { @@ -158,28 +174,63 @@ func (r *ProjectVersionRepo) MarkAsLatest(ctx context.Context, projectID, versio return err } - if !v.Prerelease { - return biz.NewErrValidationStr("cannot promote a released version to latest") + promoted, err := promoteVersionToLatestWithTx(ctx, tx, v) + if err != nil { + return err + } + + // v is the pre-update snapshot and latest is the only field the + // promotion touches, so this describes the committed row. + version := entProjectVersionToBiz(v) + version.Latest = true + + promotion = &biz.ProjectVersionPromotion{ + Promoted: promoted, + Version: version, + Project: entProjectToBiz(p), } - return promoteVersionToLatestWithTx(ctx, tx, projectID, versionID) - }) + return nil + }); err != nil { + return nil, err + } + + return promotion, nil } -func promoteVersionToLatestWithTx(ctx context.Context, tx *ent.Tx, projectID, versionID uuid.UUID) error { +// promoteVersionToLatestWithTx makes v the only latest version of its project. +// v must have been read in this transaction with a row lock, which serialises +// concurrent promotions of that same version. +// +// It reports whether v's own latest flag changed, which callers use to decide +// whether the transition is worth announcing. +// +// The lock covers v alone, not the project's other versions, so promotions of +// two different versions of the same project are not serialised against each +// other and can both report success. Closing that needs a lock on the project +// row or a unique partial index on (project_id) WHERE latest. +func promoteVersionToLatestWithTx(ctx context.Context, tx *ent.Tx, v *ent.ProjectVersion) (bool, error) { + if !v.Prerelease { + return false, biz.NewErrValidationStr("cannot promote a released version to latest") + } + if err := tx.ProjectVersion.Update(). Where( - projectversion.ProjectID(projectID), + projectversion.ProjectID(v.ProjectID), projectversion.DeletedAtIsNil(), projectversion.Latest(true), ).SetLatest(false).Exec(ctx); err != nil { - return err + return false, err } - return tx.ProjectVersion.UpdateOneID(versionID). + if err := tx.ProjectVersion.UpdateOneID(v.ID). SetLatest(true). SetUpdatedAt(time.Now()). - Exec(ctx) + Exec(ctx); err != nil { + return false, err + } + + return !v.Latest, nil } func findProjectVersionWithClient(ctx context.Context, client *ent.Client, projectID uuid.UUID, version string) (*ent.ProjectVersion, error) { diff --git a/app/controlplane/pkg/data/workflowrun.go b/app/controlplane/pkg/data/workflowrun.go index 6406a184f..b761d1d06 100644 --- a/app/controlplane/pkg/data/workflowrun.go +++ b/app/controlplane/pkg/data/workflowrun.go @@ -88,7 +88,7 @@ func (r *WorkflowRunRepo) Create(ctx context.Context, opts *biz.WorkflowRunRepoC } var p *ent.WorkflowRun - versionCreated := false + var versionCreated, versionPromoted bool // Create version and workflow in a transaction if err = WithTx(ctx, r.data.DB, func(tx *ent.Tx) error { markAsLatest := opts.MarkAsLatest != nil && *opts.MarkAsLatest @@ -118,12 +118,12 @@ func (r *WorkflowRunRepo) Create(ctx context.Context, opts *biz.WorkflowRunRepoC } if markAsLatest { - if !fresh.Prerelease { - return biz.NewErrValidationStr("cannot promote a released version to latest") - } - - if err := promoteVersionToLatestWithTx(ctx, tx, wf.ProjectID, fresh.ID); err != nil { - return fmt.Errorf("promoting version to latest: %w", err) + // Returned unwrapped so that the released-version validation + // error reads identically here and on the explicit promotion + // endpoint, which surfaces it to the user verbatim. + versionPromoted, err = promoteVersionToLatestWithTx(ctx, tx, fresh) + if err != nil { + return err } } } @@ -189,9 +189,10 @@ func (r *WorkflowRunRepo) Create(ctx context.Context, opts *biz.WorkflowRunRepoC run.ProjectVersion = entProjectVersionToBiz(version) return &biz.WorkflowRunRepoCreateResult{ - Project: entProjectToBiz(project), - Run: run, - VersionCreated: versionCreated, + Project: entProjectToBiz(project), + Run: run, + VersionCreated: versionCreated, + VersionPromoted: versionPromoted, }, nil } From d2a9db74990e582867198f2c9f0b11beb26ef53f Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Fri, 31 Jul 2026 09:24:54 +0200 Subject: [PATCH 2/2] docs(controlplane): correct the stale reachability note on MarkAsLatest The comment claimed the platform builds its "project version mark-latest" command and service endpoint on top of this method. That is not the case: the platform implements its own mark-latest over its own repositories and does not reference this use case at all. The inaccuracy is not harmless. It led a review of the surrounding change to treat these methods as a live customer-facing path and to file follow-up work on that basis. State what is actually true instead: nothing calls this today, and it is kept as the biz-layer entry point with the promotion event wired so a future caller cannot silently reintroduce the tracking gap. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 82261846-ded9-4e52-8707-d8a769402b5a --- app/controlplane/pkg/biz/projectversion.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/controlplane/pkg/biz/projectversion.go b/app/controlplane/pkg/biz/projectversion.go index a27d21516..017402ec9 100644 --- a/app/controlplane/pkg/biz/projectversion.go +++ b/app/controlplane/pkg/biz/projectversion.go @@ -149,8 +149,13 @@ func (uc *ProjectVersionUseCase) UpdateReleaseStatus(ctx context.Context, versio return uc.projectRepo.Update(ctx, versionUUID, &ProjectVersionUpdateOpts{Prerelease: &preReleaseValue}) } -// MarkAsLatest promotes a pre-release version to latest. The platform repo builds the -// "project version mark-latest" CLI command and service endpoint on top of this method. +// MarkAsLatest promotes a pre-release version to latest. +// +// Nothing calls this today: no service in this repository uses it, and the +// platform implements its own mark-latest over its own repositories rather than +// this use case. It is kept as the biz-layer entry point for the operation, and +// it dispatches the promotion event so that a future caller cannot reintroduce +// the silent-transition gap this method used to have. func (uc *ProjectVersionUseCase) MarkAsLatest(ctx context.Context, projectID, versionID string) error { ctx, span := otelx.Start(ctx, projectVersionTracer, "ProjectVersionUseCase.MarkAsLatest") defer span.End()