Skip to content

Commit 91aeeb2

Browse files
authored
fix(controlplane): emit an audit event when a project version is promoted to latest (#3317)
1 parent 0f48fd2 commit 91aeeb2

17 files changed

Lines changed: 596 additions & 48 deletions

app/controlplane/cmd/wire_gen.go

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

app/controlplane/pkg/auditor/events/project.go

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// Copyright 2025 The Chainloop Authors.
2+
// Copyright 2025-2026 The Chainloop Authors.
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
@@ -164,6 +164,10 @@ type ProjectVersionUpdated struct {
164164
VersionID *uuid.UUID `json:"version_id,omitempty"`
165165
Version string `json:"version,omitempty"`
166166
NewVersion *string `json:"new_version,omitempty"`
167+
// MarkedAsLatest reports that the update promoted this version to be the
168+
// project's latest one. Always emitted, like Prerelease on the sibling
169+
// events, so consumers can tell "not a promotion" from "older producer".
170+
MarkedAsLatest bool `json:"marked_as_latest"`
167171
}
168172

169173
func (p *ProjectVersionUpdated) ActionType() string {
@@ -183,15 +187,17 @@ func (p *ProjectVersionUpdated) ActionInfo() (json.RawMessage, error) {
183187
}
184188

185189
func (p *ProjectVersionUpdated) Description() string {
186-
desc := fmt.Sprintf("%s has updated version '%s' for project '%s'",
187-
auditor.GetActorIdentifier(), p.Version, p.ProjectName)
188-
189-
if p.NewVersion != nil {
190-
desc = fmt.Sprintf("%s has renamed version '%s' to '%s' for project '%s'",
190+
switch {
191+
case p.NewVersion != nil:
192+
return fmt.Sprintf("%s has renamed version '%s' to '%s' for project '%s'",
191193
auditor.GetActorIdentifier(), p.Version, *p.NewVersion, p.ProjectName)
194+
case p.MarkedAsLatest:
195+
return fmt.Sprintf("%s has promoted version '%s' to latest for project '%s'",
196+
auditor.GetActorIdentifier(), p.Version, p.ProjectName)
197+
default:
198+
return fmt.Sprintf("%s has updated version '%s' for project '%s'",
199+
auditor.GetActorIdentifier(), p.Version, p.ProjectName)
192200
}
193-
194-
return desc
195201
}
196202

197203
// Helper function to make role names more user-friendly

app/controlplane/pkg/auditor/events/project_test.go

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// Copyright 2025 The Chainloop Authors.
2+
// Copyright 2025-2026 The Chainloop Authors.
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
@@ -93,6 +93,35 @@ func TestProjectEvents(t *testing.T) {
9393
actor: auditor.ActorTypeUser,
9494
actorID: userUUID,
9595
},
96+
{
97+
name: "ProjectVersionUpdated without a specific change",
98+
event: &events.ProjectVersionUpdated{
99+
ProjectBase: &events.ProjectBase{
100+
ProjectID: &projectUUID,
101+
ProjectName: projectName,
102+
},
103+
VersionID: &versionUUID,
104+
Version: "v1.0.0",
105+
},
106+
expected: "testdata/projects/project_version_updated_generic.json",
107+
actor: auditor.ActorTypeUser,
108+
actorID: userUUID,
109+
},
110+
{
111+
name: "ProjectVersionUpdated marked as latest",
112+
event: &events.ProjectVersionUpdated{
113+
ProjectBase: &events.ProjectBase{
114+
ProjectID: &projectUUID,
115+
ProjectName: projectName,
116+
},
117+
VersionID: &versionUUID,
118+
Version: "v1.0.0",
119+
MarkedAsLatest: true,
120+
},
121+
expected: "testdata/projects/project_version_marked_as_latest.json",
122+
actor: auditor.ActorTypeUser,
123+
actorID: userUUID,
124+
},
96125
{
97126
name: "ProjectMembershipAdded",
98127
event: &events.ProjectMembershipAdded{
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"ActionType": "ProjectVersionUpdated",
3+
"TargetType": "Project",
4+
"TargetID": "3089bb36-e27b-428b-8009-d015c8737c56",
5+
"ActorType": "USER",
6+
"ActorID": "1089bb36-e27b-428b-8009-d015c8737c54",
7+
"ActorEmail": "john@cyberdyne.io",
8+
"ActorName": "John Connor",
9+
"OrgID": "1089bb36-e27b-428b-8009-d015c8737c54",
10+
"Description": "John Connor has promoted version 'v1.0.0' to latest for project 'test-project'",
11+
"Info": {
12+
"project_id": "3089bb36-e27b-428b-8009-d015c8737c56",
13+
"project_name": "test-project",
14+
"version_id": "5089bb36-e27b-428b-8009-d015c8737c58",
15+
"version": "v1.0.0",
16+
"marked_as_latest": true
17+
},
18+
"Digest": "sha256:02de4db635507f73918e0a5ad07039e3da666d323ab2de72bd106160a2bc7dca"
19+
}

app/controlplane/pkg/auditor/events/testdata/projects/project_version_updated.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
"project_name": "test-project",
1414
"version_id": "5089bb36-e27b-428b-8009-d015c8737c58",
1515
"version": "v1.0.0",
16-
"new_version": "v1.0.1"
16+
"new_version": "v1.0.1",
17+
"marked_as_latest": false
1718
},
18-
"Digest": "sha256:f86470cccd6d88b274433350b0ff3958b9f081a12bffcbcd20b6648d28182c1f"
19+
"Digest": "sha256:3d2d27245dbe6e2629eb88259c56194e0a0c81820fa96e3391b316a511fe18c1"
1920
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"ActionType": "ProjectVersionUpdated",
3+
"TargetType": "Project",
4+
"TargetID": "3089bb36-e27b-428b-8009-d015c8737c56",
5+
"ActorType": "USER",
6+
"ActorID": "1089bb36-e27b-428b-8009-d015c8737c54",
7+
"ActorEmail": "john@cyberdyne.io",
8+
"ActorName": "John Connor",
9+
"OrgID": "1089bb36-e27b-428b-8009-d015c8737c54",
10+
"Description": "John Connor has updated version 'v1.0.0' for project 'test-project'",
11+
"Info": {
12+
"project_id": "3089bb36-e27b-428b-8009-d015c8737c56",
13+
"project_name": "test-project",
14+
"version_id": "5089bb36-e27b-428b-8009-d015c8737c58",
15+
"version": "v1.0.0",
16+
"marked_as_latest": false
17+
},
18+
"Digest": "sha256:2d922b6b3e952d19a82db0af8d72d8a12eed1afdf19d7eddd3be7c069b241597"
19+
}

app/controlplane/pkg/biz/auditor.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,17 +34,25 @@ type AuditorUseCase struct {
3434
dispatcher *auditor.Dispatcher
3535
}
3636

37+
// NewAuditorUseCase builds an AuditorUseCase from the NATS-backed publisher.
38+
// It takes the concrete type because the publisher is nil when auditing is
39+
// disabled, and a nil pointer assigned straight to an interface would leave the
40+
// dispatcher holding a typed-nil that reports itself as enabled.
3741
func NewAuditorUseCase(p *auditor.AuditLogPublisher, logger log.Logger) *AuditorUseCase {
38-
// keep the Publisher interface nil when the publisher is disabled so the
39-
// dispatcher short-circuits instead of holding a typed-nil interface
4042
var publisher auditor.Publisher
4143
if p != nil {
4244
publisher = p
4345
}
4446

47+
return newAuditorUseCase(publisher, logger)
48+
}
49+
50+
// newAuditorUseCase builds an AuditorUseCase over any publisher. A nil
51+
// publisher makes dispatching a no-op.
52+
func newAuditorUseCase(p auditor.Publisher, logger log.Logger) *AuditorUseCase {
4553
return &AuditorUseCase{
4654
log: log.NewHelper(log.With(logger, "component", "biz/auditor")),
47-
dispatcher: auditor.NewDispatcher(publisher, logger),
55+
dispatcher: auditor.NewDispatcher(p, logger),
4856
}
4957
}
5058

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
//
2+
// Copyright 2026 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package biz
17+
18+
import (
19+
"context"
20+
"encoding/json"
21+
"io"
22+
"testing"
23+
24+
"github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext/entities"
25+
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/auditor"
26+
"github.com/go-kratos/kratos/v2/log"
27+
"github.com/google/uuid"
28+
"github.com/stretchr/testify/assert"
29+
"github.com/stretchr/testify/require"
30+
)
31+
32+
// recordingPublisher captures the audit events a use case dispatches so tests
33+
// can assert on them. The production publisher is NATS-backed, so this is the
34+
// only way to observe dispatches without a broker.
35+
type recordingPublisher struct {
36+
published []*auditor.EventPayload
37+
}
38+
39+
func (p *recordingPublisher) Publish(data *auditor.EventPayload) error {
40+
p.published = append(p.published, data)
41+
return nil
42+
}
43+
44+
// assertSingleProjectVersionEvent checks that exactly one event was recorded,
45+
// that it reports the given action for the given project, and that its payload
46+
// describes the given version. wantMarkedAsLatest pins whether the event
47+
// announces a promotion.
48+
func (p *recordingPublisher) assertSingleProjectVersionEvent(
49+
t *testing.T, wantAction string, project *Project, version *ProjectVersion, wantMarkedAsLatest bool,
50+
) {
51+
t.Helper()
52+
53+
require.Len(t, p.published, 1)
54+
got := p.published[0].Data
55+
assert.Equal(t, wantAction, got.ActionType)
56+
assert.Equal(t, &project.ID, got.TargetID)
57+
assert.Equal(t, &project.OrgID, got.OrgID)
58+
59+
var info struct {
60+
VersionID *uuid.UUID `json:"version_id"`
61+
Version string `json:"version"`
62+
MarkedAsLatest bool `json:"marked_as_latest"`
63+
}
64+
require.NoError(t, json.Unmarshal(got.Info, &info))
65+
assert.Equal(t, &version.ID, info.VersionID)
66+
assert.Equal(t, version.Version, info.Version)
67+
assert.Equal(t, wantMarkedAsLatest, info.MarkedAsLatest)
68+
}
69+
70+
// newRecordingAuditor builds an AuditorUseCase backed by a recordingPublisher,
71+
// through the same constructor production uses so the two cannot drift.
72+
func newRecordingAuditor() (*AuditorUseCase, *recordingPublisher) {
73+
publisher := &recordingPublisher{}
74+
75+
return newAuditorUseCase(publisher, log.NewStdLogger(io.Discard)), publisher
76+
}
77+
78+
// ctxWithAPITokenActor returns a context carrying an actor, required by the
79+
// audit entries that report on project resources.
80+
func ctxWithAPITokenActor(ctx context.Context) context.Context {
81+
return entities.WithCurrentAPIToken(ctx, &entities.APIToken{ID: uuid.NewString(), Name: "test-token"})
82+
}

app/controlplane/pkg/biz/projectversion.go

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"io"
2121
"time"
2222

23+
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/auditor/events"
2324
"github.com/chainloop-dev/chainloop/pkg/otelx"
2425
"github.com/chainloop-dev/chainloop/pkg/servicelogger"
2526
"github.com/go-kratos/kratos/v2/log"
@@ -51,24 +52,72 @@ type ProjectVersion struct {
5152
ProjectID uuid.UUID
5253
}
5354

55+
// ProjectVersionPromotion is the outcome of promoting a project version to be
56+
// the latest one.
57+
type ProjectVersionPromotion struct {
58+
// Promoted reports whether the promotion changed which version is the
59+
// latest one. It is false when the version already was the latest.
60+
Promoted bool
61+
// Version and Project describe the promotion for auditing purposes. Both are
62+
// populated whenever the promotion succeeded, so that a committed promotion
63+
// can always be reported.
64+
Version *ProjectVersion
65+
Project *Project
66+
}
67+
68+
// dispatchProjectVersionPromoted reports that a project version became the
69+
// latest one for its project. Every path that promotes a version funnels
70+
// through here: downstream consumers reconcile off this event, so a promotion
71+
// must be as loud as a creation, otherwise anything tracking the latest version
72+
// of the project silently falls behind.
73+
//
74+
// A promotion deliberately reuses ProjectVersionUpdated rather than declaring
75+
// its own action type, and that choice is load-bearing. Subjects are published
76+
// as "audit.<target_type>.<action_type>", and consumers subscribe to a fixed
77+
// list of them, so a new action type lands on a subject nobody is listening to
78+
// and every promotion is dropped — the very failure this event exists to
79+
// prevent, but silent. If this action type ever changes, the consumers must
80+
// subscribe to the new subject and be released FIRST; MarkedAsLatest is what
81+
// lets a consumer tell a promotion from a rename in the meantime.
82+
func dispatchProjectVersionPromoted(ctx context.Context, auditorUC *AuditorUseCase, project *Project, version *ProjectVersion) {
83+
if auditorUC == nil || project == nil || version == nil {
84+
return
85+
}
86+
87+
auditorUC.Dispatch(ctx, &events.ProjectVersionUpdated{
88+
ProjectBase: &events.ProjectBase{
89+
ProjectID: &project.ID,
90+
ProjectName: project.Name,
91+
},
92+
VersionID: &version.ID,
93+
Version: version.Version,
94+
MarkedAsLatest: true,
95+
}, &project.OrgID)
96+
}
97+
5498
type ProjectVersionRepo interface {
5599
FindByProjectAndVersion(ctx context.Context, projectID uuid.UUID, version string) (*ProjectVersion, error)
56100
Update(ctx context.Context, versionID uuid.UUID, updates *ProjectVersionUpdateOpts) (*ProjectVersion, error)
57101
Create(ctx context.Context, projectID uuid.UUID, version string, prerelease bool) (*ProjectVersion, error)
58-
MarkAsLatest(ctx context.Context, projectID, versionID uuid.UUID) error
102+
MarkAsLatest(ctx context.Context, projectID, versionID uuid.UUID) (*ProjectVersionPromotion, error)
59103
}
60104

61105
type ProjectVersionUseCase struct {
62106
projectRepo ProjectVersionRepo
107+
auditorUC *AuditorUseCase
63108
logger *log.Helper
64109
}
65110

66-
func NewProjectVersionUseCase(repo ProjectVersionRepo, l log.Logger) *ProjectVersionUseCase {
111+
func NewProjectVersionUseCase(repo ProjectVersionRepo, auditorUC *AuditorUseCase, l log.Logger) *ProjectVersionUseCase {
67112
if l == nil {
68113
l = log.NewStdLogger(io.Discard)
69114
}
70115

71-
return &ProjectVersionUseCase{projectRepo: repo, logger: servicelogger.ScopedHelper(l, "biz/project-version")}
116+
return &ProjectVersionUseCase{
117+
projectRepo: repo,
118+
auditorUC: auditorUC,
119+
logger: servicelogger.ScopedHelper(l, "biz/project-version"),
120+
}
72121
}
73122

74123
func (uc *ProjectVersionUseCase) FindByProjectAndVersion(ctx context.Context, projectID string, version string) (*ProjectVersion, error) {
@@ -100,8 +149,13 @@ func (uc *ProjectVersionUseCase) UpdateReleaseStatus(ctx context.Context, versio
100149
return uc.projectRepo.Update(ctx, versionUUID, &ProjectVersionUpdateOpts{Prerelease: &preReleaseValue})
101150
}
102151

103-
// MarkAsLatest promotes a pre-release version to latest. The platform repo builds the
104-
// "project version mark-latest" CLI command and service endpoint on top of this method.
152+
// MarkAsLatest promotes a pre-release version to latest.
153+
//
154+
// Nothing calls this today: no service in this repository uses it, and the
155+
// platform implements its own mark-latest over its own repositories rather than
156+
// this use case. It is kept as the biz-layer entry point for the operation, and
157+
// it dispatches the promotion event so that a future caller cannot reintroduce
158+
// the silent-transition gap this method used to have.
105159
func (uc *ProjectVersionUseCase) MarkAsLatest(ctx context.Context, projectID, versionID string) error {
106160
ctx, span := otelx.Start(ctx, projectVersionTracer, "ProjectVersionUseCase.MarkAsLatest")
107161
defer span.End()
@@ -116,7 +170,16 @@ func (uc *ProjectVersionUseCase) MarkAsLatest(ctx context.Context, projectID, ve
116170
return NewErrInvalidUUID(err)
117171
}
118172

119-
return uc.projectRepo.MarkAsLatest(ctx, projectUUID, versionUUID)
173+
promotion, err := uc.projectRepo.MarkAsLatest(ctx, projectUUID, versionUUID)
174+
if err != nil {
175+
return err
176+
}
177+
178+
if promotion.Promoted {
179+
dispatchProjectVersionPromoted(ctx, uc.auditorUC, promotion.Project, promotion.Version)
180+
}
181+
182+
return nil
120183
}
121184

122185
func (uc *ProjectVersionUseCase) Create(ctx context.Context, projectID, version string, prerelease bool) (*ProjectVersion, error) {

0 commit comments

Comments
 (0)