Skip to content

Commit cc153ae

Browse files
authored
refactor(auditor): share audit dispatch logic between control plane and CAS (#3191)
1 parent f142a75 commit cc153ae

5 files changed

Lines changed: 294 additions & 82 deletions

File tree

app/artifact-cas/internal/service/auditor.go

Lines changed: 18 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -16,44 +16,42 @@
1616
package service
1717

1818
import (
19-
"fmt"
20-
2119
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/auditor"
2220
casJWT "github.com/chainloop-dev/chainloop/internal/robotaccount/cas"
2321
"github.com/chainloop-dev/chainloop/pkg/servicelogger"
24-
"github.com/getsentry/sentry-go"
2522
"github.com/go-kratos/kratos/v2/log"
2623
"github.com/google/uuid"
2724
)
2825

29-
// eventPublisher is the subset of auditor.AuditLogPublisher used by the dispatcher
30-
type eventPublisher interface {
31-
Publish(data *auditor.EventPayload) error
32-
}
33-
34-
// AuditDispatcher publishes CAS audit events. Unlike the control plane's
35-
// biz.AuditorUseCase, the actor is always SYSTEM (CAS JWTs carry no user
36-
// identity) and the org comes from the JWT claims instead of the request context.
26+
// AuditDispatcher publishes CAS audit events. It delegates the shared
27+
// generate -> publish -> error-reporting flow to the control plane's
28+
// auditor.Dispatcher and only owns the CAS-specific actor/org policy: unlike
29+
// the control plane's biz.AuditorUseCase, the actor is always SYSTEM (CAS JWTs
30+
// carry no user identity) and the org comes from the JWT claims instead of the
31+
// request context.
3732
type AuditDispatcher struct {
38-
// nil when NATS is not configured, making the dispatcher a no-op
39-
publisher eventPublisher
40-
log *log.Helper
33+
dispatcher *auditor.Dispatcher
34+
log *log.Helper
4135
}
4236

4337
func NewAuditDispatcher(publisher *auditor.AuditLogPublisher, logger log.Logger) *AuditDispatcher {
44-
d := &AuditDispatcher{log: servicelogger.ScopedHelper(logger, "audit-dispatcher")}
45-
// keep the interface nil when the publisher is disabled so shouldEmit short-circuits
38+
// keep the Publisher interface nil when the publisher is disabled so the
39+
// dispatcher short-circuits instead of holding a typed-nil interface
40+
var p auditor.Publisher
4641
if publisher != nil {
47-
d.publisher = publisher
42+
p = publisher
4843
}
4944

50-
return d
45+
return &AuditDispatcher{
46+
dispatcher: auditor.NewDispatcher(p, logger),
47+
log: servicelogger.ScopedHelper(logger, "audit-dispatcher"),
48+
}
5149
}
5250

5351
// shouldEmit returns true when Dispatch would actually publish an event for the
5452
// given claims. Hooks use it to skip extra work (e.g. backend Describe round-trips).
5553
func (d *AuditDispatcher) shouldEmit(claims *casJWT.Claims) bool {
56-
return d != nil && d.publisher != nil && claims != nil && !claims.SourceInternal
54+
return d != nil && d.dispatcher.Enabled() && claims != nil && !claims.SourceInternal
5755
}
5856

5957
// Dispatch generates and publishes an audit event with a SYSTEM actor and the
@@ -70,18 +68,8 @@ func (d *AuditDispatcher) Dispatch(entry auditor.LogEntry, claims *casJWT.Claims
7068
return
7169
}
7270

73-
payload, err := auditor.GenerateAuditEvent(entry,
71+
d.dispatcher.Dispatch(entry,
7472
auditor.WithActor(auditor.ActorTypeSystem, uuid.Nil, "", ""),
7573
auditor.WithOrgID(orgID),
7674
)
77-
if err != nil {
78-
d.log.Errorw("msg", "failed to generate audit event", "error", err)
79-
sentry.CaptureException(fmt.Errorf("failed to generate audit event: %w", err))
80-
return
81-
}
82-
83-
if err := d.publisher.Publish(payload); err != nil {
84-
d.log.Errorw("msg", "failed to publish audit event", "error", err)
85-
sentry.CaptureException(fmt.Errorf("failed to publish audit event: %w", err))
86-
}
8775
}

app/artifact-cas/internal/service/auditor_test.go

Lines changed: 50 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,18 @@ func (f *fakePublisher) Publish(data *auditor.EventPayload) error {
4444
return nil
4545
}
4646

47-
func newTestDispatcher(p eventPublisher) *AuditDispatcher {
48-
return &AuditDispatcher{publisher: p, log: servicelogger.ScopedHelper(log.DefaultLogger, "test")}
47+
// newTestDispatcher builds an AuditDispatcher backed by the given fake. A nil
48+
// fake leaves the underlying publisher disabled, mirroring NewAuditDispatcher.
49+
func newTestDispatcher(p *fakePublisher) *AuditDispatcher {
50+
var pub auditor.Publisher
51+
if p != nil {
52+
pub = p
53+
}
54+
55+
return &AuditDispatcher{
56+
dispatcher: auditor.NewDispatcher(pub, log.DefaultLogger),
57+
log: servicelogger.ScopedHelper(log.DefaultLogger, "test"),
58+
}
4959
}
5060

5161
func testUploadedEntry() auditor.LogEntry {
@@ -63,51 +73,53 @@ const testOrgID = "1089bb36-e27b-428b-8009-d015c8737c54"
6373

6474
func TestAuditDispatcherDispatch(t *testing.T) {
6575
tests := []struct {
66-
name string
67-
dispatcher *AuditDispatcher
76+
name string
77+
// publisher is the fake backing the dispatcher; nil means the dispatcher
78+
// is disabled (no NATS). nilDispatcher exercises the nil-receiver path.
79+
publisher *fakePublisher
80+
nilDispatcher bool
6881
entry auditor.LogEntry
6982
claims *casJWT.Claims
7083
wantPublished int
7184
}{
7285
{
73-
name: "nil dispatcher is a no-op",
74-
dispatcher: nil,
75-
entry: testUploadedEntry(),
76-
claims: &casJWT.Claims{OrgID: testOrgID},
86+
name: "nil dispatcher is a no-op",
87+
nilDispatcher: true,
88+
entry: testUploadedEntry(),
89+
claims: &casJWT.Claims{OrgID: testOrgID},
7790
},
7891
{
79-
name: "nil publisher is a no-op",
80-
dispatcher: newTestDispatcher(nil),
81-
entry: testUploadedEntry(),
82-
claims: &casJWT.Claims{OrgID: testOrgID},
92+
name: "nil publisher is a no-op",
93+
entry: testUploadedEntry(),
94+
claims: &casJWT.Claims{OrgID: testOrgID},
8395
},
8496
{
85-
name: "internal control plane traffic is skipped",
86-
dispatcher: newTestDispatcher(&fakePublisher{}),
87-
entry: testUploadedEntry(),
88-
claims: &casJWT.Claims{OrgID: testOrgID, SourceInternal: true},
97+
name: "internal control plane traffic is skipped",
98+
publisher: &fakePublisher{},
99+
entry: testUploadedEntry(),
100+
claims: &casJWT.Claims{OrgID: testOrgID, SourceInternal: true},
89101
},
90102
{
91-
name: "invalid org id is skipped",
92-
dispatcher: newTestDispatcher(&fakePublisher{}),
93-
entry: testUploadedEntry(),
94-
claims: &casJWT.Claims{OrgID: "not-an-uuid"},
103+
name: "invalid org id is skipped",
104+
publisher: &fakePublisher{},
105+
entry: testUploadedEntry(),
106+
claims: &casJWT.Claims{OrgID: "not-an-uuid"},
95107
},
96108
{
97-
name: "invalid entry is skipped",
98-
dispatcher: newTestDispatcher(&fakePublisher{}),
99-
entry: &events.CASArtifactUploaded{CASArtifactBase: &events.CASArtifactBase{}},
100-
claims: &casJWT.Claims{OrgID: testOrgID},
109+
name: "invalid entry is skipped",
110+
publisher: &fakePublisher{},
111+
entry: &events.CASArtifactUploaded{CASArtifactBase: &events.CASArtifactBase{}},
112+
claims: &casJWT.Claims{OrgID: testOrgID},
101113
},
102114
{
103-
name: "publish errors are swallowed",
104-
dispatcher: newTestDispatcher(&fakePublisher{err: errors.New("nats is down")}),
105-
entry: testUploadedEntry(),
106-
claims: &casJWT.Claims{OrgID: testOrgID},
115+
name: "publish errors are swallowed",
116+
publisher: &fakePublisher{err: errors.New("nats is down")},
117+
entry: testUploadedEntry(),
118+
claims: &casJWT.Claims{OrgID: testOrgID},
107119
},
108120
{
109121
name: "client traffic is published",
110-
dispatcher: newTestDispatcher(&fakePublisher{}),
122+
publisher: &fakePublisher{},
111123
entry: testUploadedEntry(),
112124
claims: &casJWT.Claims{OrgID: testOrgID},
113125
wantPublished: 1,
@@ -116,20 +128,24 @@ func TestAuditDispatcherDispatch(t *testing.T) {
116128

117129
for _, tc := range tests {
118130
t.Run(tc.name, func(t *testing.T) {
131+
var d *AuditDispatcher
132+
if !tc.nilDispatcher {
133+
d = newTestDispatcher(tc.publisher)
134+
}
135+
119136
// must never panic nor return an error
120-
tc.dispatcher.Dispatch(tc.entry, tc.claims)
137+
d.Dispatch(tc.entry, tc.claims)
121138

122-
if tc.dispatcher == nil || tc.dispatcher.publisher == nil {
139+
if tc.publisher == nil {
123140
return
124141
}
125-
fake := tc.dispatcher.publisher.(*fakePublisher)
126142

127-
require.Len(t, fake.published, tc.wantPublished)
143+
require.Len(t, tc.publisher.published, tc.wantPublished)
128144
if tc.wantPublished == 0 {
129145
return
130146
}
131147

132-
got := fake.published[0]
148+
got := tc.publisher.published[0]
133149
assert.Equal(t, auditor.AuditEventType, got.EventType)
134150
assert.Equal(t, events.CASArtifactUploadedActionType, got.Data.ActionType)
135151
assert.Equal(t, events.CASArtifactType, got.Data.TargetType)
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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 auditor
17+
18+
import (
19+
"fmt"
20+
21+
"github.com/chainloop-dev/chainloop/pkg/servicelogger"
22+
"github.com/getsentry/sentry-go"
23+
"github.com/go-kratos/kratos/v2/log"
24+
)
25+
26+
// Publisher publishes generated audit event payloads to the event bus.
27+
// Implemented by *AuditLogPublisher; abstracted so it can be faked in tests and
28+
// so a nil publisher can act as a no-op (NATS not configured).
29+
type Publisher interface {
30+
Publish(data *EventPayload) error
31+
}
32+
33+
// Dispatcher centralizes the generate -> publish -> error-reporting flow shared
34+
// by every component that emits audit events (e.g. the control plane's
35+
// biz.AuditorUseCase and the Artifact CAS). Callers resolve the actor and
36+
// organization themselves and pass them as GeneratorOptions, so each component
37+
// keeps its own actor/org policy (request context vs JWT claims) while sharing
38+
// the common dispatch machinery.
39+
type Dispatcher struct {
40+
// nil when the publisher is not configured, making the dispatcher a no-op
41+
publisher Publisher
42+
log *log.Helper
43+
}
44+
45+
// NewDispatcher builds a Dispatcher. A nil publisher (e.g. NATS not configured)
46+
// turns Dispatch into a no-op and makes Enabled report false.
47+
func NewDispatcher(publisher Publisher, logger log.Logger) *Dispatcher {
48+
return &Dispatcher{
49+
publisher: publisher,
50+
log: servicelogger.ScopedHelper(logger, "auditor-dispatcher"),
51+
}
52+
}
53+
54+
// Enabled reports whether Dispatch would actually publish an event. Callers can
55+
// use it to skip extra work when the dispatcher is a no-op.
56+
func (d *Dispatcher) Enabled() bool {
57+
return d != nil && d.publisher != nil
58+
}
59+
60+
// Dispatch generates the audit event from entry and the given options and
61+
// publishes it. Best-effort: failures are logged and reported to Sentry, never
62+
// returned, so they can't fail or slow down the caller. A disabled dispatcher
63+
// is a no-op.
64+
func (d *Dispatcher) Dispatch(entry LogEntry, opts ...GeneratorOption) {
65+
if !d.Enabled() {
66+
return
67+
}
68+
69+
payload, err := GenerateAuditEvent(entry, opts...)
70+
if err != nil {
71+
d.log.Errorw("msg", "failed to generate audit event", "error", err)
72+
sentry.CaptureException(fmt.Errorf("failed to generate audit event: %w", err))
73+
return
74+
}
75+
76+
if err := d.publisher.Publish(payload); err != nil {
77+
d.log.Errorw("msg", "failed to publish audit event", "error", err)
78+
sentry.CaptureException(fmt.Errorf("failed to publish audit event: %w", err))
79+
}
80+
}

0 commit comments

Comments
 (0)