Skip to content

Commit f48ecc0

Browse files
authored
refactor: audit log record (#1654)
1 parent 5c7afbb commit f48ecc0

File tree

14 files changed

+224
-267
lines changed

14 files changed

+224
-267
lines changed

internal/persis/fileaudit/cleaner.go

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,38 @@
11
package fileaudit
22

33
import (
4+
"context"
5+
"encoding/json"
46
"log/slog"
57
"os"
68
"path/filepath"
79
"strings"
810
"sync"
911
"time"
12+
13+
"github.com/dagu-org/dagu/internal/service/audit"
1014
)
1115

16+
// appendFn is a function that appends an audit entry to the store.
17+
type appendFn func(ctx context.Context, entry *audit.Entry) error
18+
1219
// cleaner handles periodic cleanup of expired audit log files.
1320
type cleaner struct {
1421
baseDir string
1522
retentionDays int
23+
appendFn appendFn
1624
stopCh chan struct{}
1725
stopOnce sync.Once
1826
}
1927

2028
// newCleaner creates and starts a cleaner that purges expired audit log files.
2129
// It runs purgeExpiredFiles immediately, then every 24 hours.
22-
func newCleaner(baseDir string, retentionDays int) *cleaner {
30+
// The appendFn is called to record cleanup events in the audit log.
31+
func newCleaner(baseDir string, retentionDays int, fn appendFn) *cleaner {
2332
c := &cleaner{
2433
baseDir: baseDir,
2534
retentionDays: retentionDays,
35+
appendFn: fn,
2636
stopCh: make(chan struct{}),
2737
}
2838
go c.run()
@@ -75,7 +85,7 @@ func (c *cleaner) purgeExpiredFiles() {
7585
cutoff := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).
7686
AddDate(0, 0, -c.retentionDays)
7787

78-
removed := 0
88+
var purgedDates []string
7989
for _, entry := range entries {
8090
if entry.IsDir() {
8191
continue
@@ -100,13 +110,33 @@ func (c *cleaner) purgeExpiredFiles() {
100110
slog.String("error", err.Error()))
101111
continue
102112
}
103-
removed++
113+
purgedDates = append(purgedDates, datePart)
104114
}
105115
}
106116

107-
if removed > 0 {
117+
if len(purgedDates) > 0 {
108118
slog.Info("fileaudit: purged expired audit log files",
109-
slog.Int("removed", removed),
119+
slog.Int("removed", len(purgedDates)),
110120
slog.Int("retentionDays", c.retentionDays))
121+
122+
if c.appendFn != nil {
123+
details, err := json.Marshal(map[string]any{
124+
"purged_from": purgedDates[0],
125+
"purged_to": purgedDates[len(purgedDates)-1],
126+
"files_removed": len(purgedDates),
127+
"retention_days": c.retentionDays,
128+
})
129+
if err != nil {
130+
slog.Warn("fileaudit: failed to marshal cleanup audit details",
131+
slog.String("error", err.Error()))
132+
details = []byte("{}")
133+
}
134+
entry := audit.NewEntry(audit.CategorySystem, "audit_cleanup", "", "system").
135+
WithDetails(string(details))
136+
if err := c.appendFn(context.Background(), entry); err != nil {
137+
slog.Warn("fileaudit: failed to log cleanup audit entry",
138+
slog.String("error", err.Error()))
139+
}
140+
}
111141
}
112142
}

internal/persis/fileaudit/cleaner_test.go

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
package fileaudit
22

33
import (
4+
"context"
45
"os"
56
"path/filepath"
7+
"sync"
68
"testing"
79
"time"
810

11+
"github.com/dagu-org/dagu/internal/service/audit"
912
"github.com/stretchr/testify/assert"
1013
"github.com/stretchr/testify/require"
1114
)
@@ -147,9 +150,7 @@ func TestPurgeExpiredFiles_ZeroRetentionSkipsCleanup(t *testing.T) {
147150
}
148151

149152
func TestCleaner_StopIsIdempotent(t *testing.T) {
150-
dir := t.TempDir()
151-
152-
c := newCleaner(dir, 7)
153+
c := newTestCleaner(t.TempDir(), 7)
153154

154155
// Calling stop multiple times should not panic
155156
assert.NotPanics(t, func() {
@@ -168,6 +169,57 @@ func TestPurgeExpiredFiles_NonexistentDirectory(t *testing.T) {
168169
})
169170
}
170171

172+
func TestPurgeExpiredFiles_LogsCleanupAuditEntry(t *testing.T) {
173+
dir := t.TempDir()
174+
175+
// Create two expired files with known dates
176+
oldDate := time.Now().UTC().AddDate(0, 0, -30).Format(dateFormat)
177+
olderDate := time.Now().UTC().AddDate(0, 0, -31).Format(dateFormat)
178+
createTestFile(t, dir, oldDate+auditFileExtension)
179+
createTestFile(t, dir, olderDate+auditFileExtension)
180+
181+
var mu sync.Mutex
182+
var entries []*audit.Entry
183+
fn := func(_ context.Context, entry *audit.Entry) error {
184+
mu.Lock()
185+
defer mu.Unlock()
186+
entries = append(entries, entry)
187+
return nil
188+
}
189+
190+
c := &cleaner{baseDir: dir, retentionDays: 7, appendFn: fn, stopCh: make(chan struct{})}
191+
c.purgeExpiredFiles()
192+
193+
require.Len(t, entries, 1)
194+
assert.Equal(t, audit.CategorySystem, entries[0].Category)
195+
assert.Equal(t, "audit_cleanup", entries[0].Action)
196+
assert.Equal(t, "", entries[0].UserID)
197+
assert.Equal(t, "system", entries[0].Username)
198+
assert.Contains(t, entries[0].Details, `"files_removed":2`)
199+
assert.Contains(t, entries[0].Details, `"retention_days":7`)
200+
assert.Contains(t, entries[0].Details, `"purged_from"`)
201+
assert.Contains(t, entries[0].Details, `"purged_to"`)
202+
}
203+
204+
func TestPurgeExpiredFiles_NoEntryWhenNothingPurged(t *testing.T) {
205+
dir := t.TempDir()
206+
207+
// Create only a recent file — nothing to purge
208+
recentDate := time.Now().UTC().AddDate(0, 0, -1).Format(dateFormat)
209+
createTestFile(t, dir, recentDate+auditFileExtension)
210+
211+
called := false
212+
fn := func(_ context.Context, _ *audit.Entry) error {
213+
called = true
214+
return nil
215+
}
216+
217+
c := &cleaner{baseDir: dir, retentionDays: 7, appendFn: fn, stopCh: make(chan struct{})}
218+
c.purgeExpiredFiles()
219+
220+
assert.False(t, called, "appendFn should not be called when no files are purged")
221+
}
222+
171223
func TestPurgeExpiredFiles_BoundaryDate(t *testing.T) {
172224
dir := t.TempDir()
173225

internal/persis/fileaudit/store.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ func New(baseDir string, retentionDays int) (*Store, error) {
5454
s := &Store{baseDir: baseDir}
5555

5656
if retentionDays > 0 {
57-
s.cleaner = newCleaner(baseDir, retentionDays)
57+
s.cleaner = newCleaner(baseDir, retentionDays, s.Append)
5858
}
5959

6060
return s, nil

internal/service/audit/entry.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const (
1919
CategoryWebhook Category = "webhook"
2020
CategoryGitSync Category = "git_sync"
2121
CategoryAgent Category = "agent"
22+
CategorySystem Category = "system"
2223
)
2324

2425
// Entry represents a single audit log entry.

internal/service/frontend/api/v1/agent_config.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ func (a *API) UpdateAgentConfig(ctx context.Context, request api.UpdateAgentConf
8686
return nil, errFailedToSaveAgentConfig
8787
}
8888

89-
a.logAuditEntry(ctx, audit.CategoryAgent, auditActionAgentConfigUpdate, buildAgentConfigChanges(request.Body))
89+
a.logAudit(ctx, audit.CategoryAgent, auditActionAgentConfigUpdate, buildAgentConfigChanges(request.Body))
9090

9191
return api.UpdateAgentConfig200JSONResponse(toAgentConfigResponse(cfg)), nil
9292
}

internal/service/frontend/api/v1/agent_models.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ func (a *API) CreateAgentModel(ctx context.Context, request api.CreateAgentModel
148148
// If this is the first model, auto-set as default
149149
a.autoSetDefaultModel(ctx, id)
150150

151-
a.logAuditEntry(ctx, audit.CategoryAgent, auditActionModelCreate, map[string]any{
151+
a.logAudit(ctx, audit.CategoryAgent, auditActionModelCreate, map[string]any{
152152
"model_id": id,
153153
"name": body.Name,
154154
"provider": string(body.Provider),
@@ -196,7 +196,7 @@ func (a *API) UpdateAgentModel(ctx context.Context, request api.UpdateAgentModel
196196
return nil, &Error{Code: api.ErrorCodeInternalError, Message: "Failed to update model", HTTPStatus: http.StatusInternalServerError}
197197
}
198198

199-
a.logAuditEntry(ctx, audit.CategoryAgent, auditActionModelUpdate, map[string]any{
199+
a.logAudit(ctx, audit.CategoryAgent, auditActionModelUpdate, map[string]any{
200200
"model_id": request.ModelId,
201201
})
202202

@@ -223,7 +223,7 @@ func (a *API) DeleteAgentModel(ctx context.Context, request api.DeleteAgentModel
223223
// If deleted model was default, reset to first remaining
224224
a.resetDefaultIfNeeded(ctx, request.ModelId)
225225

226-
a.logAuditEntry(ctx, audit.CategoryAgent, auditActionModelDelete, map[string]any{
226+
a.logAudit(ctx, audit.CategoryAgent, auditActionModelDelete, map[string]any{
227227
"model_id": request.ModelId,
228228
})
229229

@@ -262,7 +262,7 @@ func (a *API) SetDefaultAgentModel(ctx context.Context, request api.SetDefaultAg
262262
return nil, errFailedToSaveAgentConfig
263263
}
264264

265-
a.logAuditEntry(ctx, audit.CategoryAgent, auditActionModelSetDef, map[string]any{
265+
a.logAudit(ctx, audit.CategoryAgent, auditActionModelSetDef, map[string]any{
266266
"model_id": modelID,
267267
})
268268

internal/service/frontend/api/v1/api.go

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"errors"
77
"fmt"
8+
"log/slog"
89
"net/http"
910
"path"
1011
"reflect"
@@ -469,22 +470,44 @@ func (a *API) requireUserManagement() error {
469470
return nil
470471
}
471472

472-
// logAuditEntry logs an audit entry with the specified category and action details.
473-
// It silently returns if the audit service is not configured or if no user is present in the context.
474-
func (a *API) logAuditEntry(ctx context.Context, category audit.Category, action string, details any) {
475-
user, ok := auth.UserFromContext(ctx)
476-
if a.auditService == nil || !ok {
473+
// logAudit logs an audit entry with the specified category, action, and details.
474+
// It silently returns if the audit service is not configured.
475+
// User and IP are extracted from context; missing user is allowed (recorded as empty).
476+
func (a *API) logAudit(ctx context.Context, category audit.Category, action string, details any) {
477+
if a.auditService == nil {
477478
return
478479
}
479480

480-
detailsJSON, _ := json.Marshal(details)
481+
var userID, username string
482+
if user, ok := auth.UserFromContext(ctx); ok && user != nil {
483+
userID = user.ID
484+
username = user.Username
485+
}
486+
481487
clientIP, _ := auth.ClientIPFromContext(ctx)
482488

483-
entry := audit.NewEntry(category, action, user.ID, user.Username).
484-
WithDetails(string(detailsJSON)).
489+
var detailsStr string
490+
if details != nil {
491+
detailsJSON, err := json.Marshal(details)
492+
if err != nil {
493+
logger.Warn(ctx, "Failed to marshal audit details", tag.Error(err))
494+
detailsStr = "{}"
495+
} else {
496+
detailsStr = string(detailsJSON)
497+
}
498+
}
499+
500+
entry := audit.NewEntry(category, action, userID, username).
501+
WithDetails(detailsStr).
485502
WithIPAddress(clientIP)
486503

487-
_ = a.auditService.Log(ctx, entry)
504+
if err := a.auditService.Log(ctx, entry); err != nil {
505+
logger.Warn(ctx, "Failed to write audit log",
506+
tag.Error(err),
507+
slog.String("action", action),
508+
slog.String("category", string(category)),
509+
)
510+
}
488511
}
489512

490513
// ptrOf returns a pointer to v, or nil if v is the zero value for its type.

0 commit comments

Comments
 (0)