• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

umputun / stash / 20632463775

01 Jan 2026 04:19AM UTC coverage: 83.735% (-0.2%) from 83.964%
20632463775

push

github

web-flow
feat(audit): add audit trail logging (#52)

* docs: add audit trail implementation plan

Related to audit logging feature with admin-only access,
middleware-based capture, and POST /audit/query API.

* feat(audit): add audit trail foundation (tasks 1-3)

- Add AuditAction, AuditResult, ActorType enums
- Add admin flag to auth config with IsAdmin() method
- Add audit_log table (SQLite + PostgreSQL)
- Implement LogAudit, QueryAudit, DeleteAuditOlderThan methods
- Update plan with completed tasks

Related to audit trail feature

* feat(audit): add audit trail foundation (tasks 1-3)

- AuditAction, AuditResult, ActorType enums with go-pkgz/enum
- Admin flag in auth config with IsAdmin() method
- AuditEntry and AuditQuery types in store layer
- LogAudit, QueryAudit, DeleteAuditOlderThan store methods
- auditor middleware capturing status/bytes for /kv/* routes
- POST /audit/query handler with admin access check
- CLI flags: --audit.enabled, --audit.retention, --audit.query-limit
- Background cleanup goroutine for expired audit entries

* fix(audit): reorder middleware to capture denied requests

- Audit middleware now runs before auth to capture 401/403 rejections
- Add test for 401 mapping to denied result
- Add test verifying audit captures when auth short-circuits

* feat(api): return 201 Created for new keys, 200 OK for updates

- store.Set now returns (created bool, err) to distinguish create vs update
- api handler returns 201 Created when key is new, 200 OK when existing
- audit trail distinguishes create vs update actions based on response
- token masking in audit logs now uses 4 chars (matching auth.go)
- added composite index idx_audit_ts_key for better query performance
- updated Go, Python SDKs to accept 201 status code

related #51

* test: add audit trail integration test, fix e2e for 201 status

- add TestIntegration_AuditTrail verifying audit log for KV operations
- update e2e tests to accept 201 Created for new key creation

447 of 545 new or added lines in 10 files covered. (82.02%)

1 existing line in 1 file now uncovered.

3748 of 4476 relevant lines covered (83.74%)

82.77 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

89.2
/app/server/api/handler.go
1
// Package api provides HTTP handlers for the KV API.
2
package api
3

4
import (
5
        "context"
6
        "encoding/base64"
7
        "errors"
8
        "io"
9
        "net/http"
10
        "strings"
11
        "time"
12

13
        log "github.com/go-pkgz/lgr"
14
        "github.com/go-pkgz/rest"
15
        "github.com/go-pkgz/routegroup"
16

17
        "github.com/umputun/stash/app/enum"
18
        "github.com/umputun/stash/app/git"
19
        "github.com/umputun/stash/app/server/internal/cookie"
20
        "github.com/umputun/stash/app/store"
21
        "github.com/umputun/stash/lib/stash"
22
)
23

24
//go:generate moq -out mocks/kvstore.go -pkg mocks -skip-ensure -fmt goimports . KVStore
25
//go:generate moq -out mocks/authprovider.go -pkg mocks -skip-ensure -fmt goimports . AuthProvider
26
//go:generate moq -out mocks/formatvalidator.go -pkg mocks -skip-ensure -fmt goimports . FormatValidator
27
//go:generate moq -out mocks/gitservice.go -pkg mocks -skip-ensure -fmt goimports . GitService
28

29
// GitService defines the interface for git operations.
30
type GitService interface {
31
        Commit(req git.CommitRequest) error
32
        Delete(key string, author git.Author) error
33
        History(key string, limit int) ([]git.HistoryEntry, error)
34
        GetRevision(key string, rev string) ([]byte, string, error)
35
}
36

37
// KVStore defines the interface for key-value storage operations.
38
type KVStore interface {
39
        Get(ctx context.Context, key string) ([]byte, error)
40
        GetWithFormat(ctx context.Context, key string) ([]byte, string, error)
41
        Set(ctx context.Context, key string, value []byte, format string) (created bool, err error)
42
        Delete(ctx context.Context, key string) error
43
        List(ctx context.Context, filter enum.SecretsFilter) ([]store.KeyInfo, error)
44
        SecretsEnabled() bool
45
}
46

47
// AuthProvider defines the interface for authentication operations.
48
type AuthProvider interface {
49
        Enabled() bool
50
        GetSessionUser(ctx context.Context, token string) (string, bool)
51
        FilterUserKeys(username string, keys []string) []string
52
        FilterTokenKeys(token string, keys []string) []string
53
        FilterPublicKeys(keys []string) []string
54
        HasTokenACL(token string) bool
55
}
56

57
// FormatValidator defines the interface for format validation.
58
type FormatValidator interface {
59
        IsValidFormat(format string) bool
60
}
61

62
// Handler handles API requests for /kv/* endpoints.
63
type Handler struct {
64
        store           KVStore
65
        auth            AuthProvider
66
        formatValidator FormatValidator
67
        git             GitService
68
}
69

70
// New creates a new API handler.
71
func New(st KVStore, auth AuthProvider, fv FormatValidator, gs GitService) *Handler {
41✔
72
        return &Handler{
41✔
73
                store:           st,
41✔
74
                auth:            auth,
41✔
75
                formatValidator: fv,
41✔
76
                git:             gs,
41✔
77
        }
41✔
78
}
41✔
79

80
// Register registers API routes on the given router.
81
func (h *Handler) Register(r *routegroup.Bundle) {
×
82
        r.HandleFunc("GET /{$}", h.handleList)                 // list keys (must be before {key...})
×
83
        r.HandleFunc("GET /history/{key...}", h.handleHistory) // get key history (before generic key)
×
84
        r.HandleFunc("GET /{key...}", h.handleGet)             // get specific key
×
85
        r.HandleFunc("PUT /{key...}", h.handleSet)             // set key
×
86
        r.HandleFunc("DELETE /{key...}", h.handleDelete)
×
87
}
×
88

89
// handleList returns all keys the caller has read access to.
90
// GET /kv?prefix=app/config (filter by prefix)
91
// GET /kv?filter=secrets (filter to secrets only)
92
// GET /kv?filter=keys (filter to non-secrets only)
93
func (h *Handler) handleList(w http.ResponseWriter, r *http.Request) {
7✔
94
        // parse secrets filter query param
7✔
95
        filter := enum.SecretsFilterAll
7✔
96
        if filterParam := r.URL.Query().Get("filter"); filterParam != "" {
10✔
97
                parsed, err := enum.ParseSecretsFilter(filterParam)
3✔
98
                if err != nil {
4✔
99
                        rest.SendErrorJSON(w, r, log.Default(), http.StatusBadRequest, err, "invalid filter parameter")
1✔
100
                        return
1✔
101
                }
1✔
102
                filter = parsed
2✔
103
        }
104

105
        keys, err := h.store.List(r.Context(), filter)
6✔
106
        if err != nil {
7✔
107
                rest.SendErrorJSON(w, r, log.Default(), http.StatusInternalServerError, err, "failed to list keys")
1✔
108
                return
1✔
109
        }
1✔
110

111
        // extract key names for filtering
112
        keyNames := make([]string, len(keys))
5✔
113
        for i, k := range keys {
14✔
114
                keyNames[i] = k.Key
9✔
115
        }
9✔
116

117
        // filter by auth permissions
118
        filteredNames := h.filterKeysByAuth(r, keyNames)
5✔
119
        if filteredNames == nil {
5✔
120
                // no valid auth, but this shouldn't happen since tokenAuth middleware already checked
×
121
                rest.SendErrorJSON(w, r, log.Default(), http.StatusUnauthorized, nil, "unauthorized")
×
122
                return
×
123
        }
×
124

125
        // convert filtered names back to KeyInfo slice
126
        nameSet := make(map[string]bool, len(filteredNames))
5✔
127
        for _, name := range filteredNames {
14✔
128
                nameSet[name] = true
9✔
129
        }
9✔
130
        filtered := make([]store.KeyInfo, 0, len(filteredNames))
5✔
131
        for _, k := range keys {
14✔
132
                if nameSet[k.Key] {
18✔
133
                        filtered = append(filtered, k)
9✔
134
                }
9✔
135
        }
136

137
        // filter by prefix if specified
138
        prefix := r.URL.Query().Get("prefix")
5✔
139
        if prefix != "" {
6✔
140
                var prefixed []store.KeyInfo
1✔
141
                for _, k := range filtered {
4✔
142
                        if strings.HasPrefix(k.Key, prefix) {
5✔
143
                                prefixed = append(prefixed, k)
2✔
144
                        }
2✔
145
                }
146
                filtered = prefixed
1✔
147
        }
148

149
        log.Printf("[DEBUG] list keys: %d found, %d after auth filter", len(keys), len(filtered))
5✔
150
        rest.RenderJSON(w, filtered)
5✔
151
}
152

153
// filterKeysByAuth filters keys based on the caller's auth credentials.
154
// returns nil if auth is required but caller has no valid credentials.
155
// priority: session cookie > Bearer token > public ACL
156
func (h *Handler) filterKeysByAuth(r *http.Request, keys []string) []string {
14✔
157
        // no auth = return all keys
14✔
158
        if h.auth == nil || !h.auth.Enabled() {
23✔
159
                return keys
9✔
160
        }
9✔
161

162
        // check session cookie first (authenticated user has priority over public)
163
        for _, cookieName := range cookie.SessionCookieNames {
15✔
164
                c, err := r.Cookie(cookieName)
10✔
165
                if err != nil {
19✔
166
                        continue
9✔
167
                }
168
                username, valid := h.auth.GetSessionUser(r.Context(), c.Value)
1✔
169
                if valid {
2✔
170
                        return h.auth.FilterUserKeys(username, keys)
1✔
171
                }
1✔
172
        }
173

174
        // check Bearer token (authenticated token has priority over public)
175
        authHeader := r.Header.Get("Authorization")
4✔
176
        if token, found := strings.CutPrefix(authHeader, "Bearer "); found {
5✔
177
                if filtered := h.auth.FilterTokenKeys(token, keys); filtered != nil {
2✔
178
                        return filtered
1✔
179
                }
1✔
180
        }
181

182
        // fall back to public access for unauthenticated requests
183
        if filtered := h.auth.FilterPublicKeys(keys); filtered != nil {
4✔
184
                return filtered
1✔
185
        }
1✔
186

187
        return nil // no valid auth
2✔
188
}
189

190
// handleGet retrieves the value for a key.
191
// GET /kv/{key...}
192
func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) {
5✔
193
        key := store.NormalizeKey(r.PathValue("key"))
5✔
194
        if key == "" {
6✔
195
                rest.SendErrorJSON(w, r, log.Default(), http.StatusBadRequest, nil, "key is required")
1✔
196
                return
1✔
197
        }
1✔
198

199
        value, format, err := h.store.GetWithFormat(r.Context(), key)
4✔
200
        if errors.Is(err, store.ErrSecretsNotConfigured) {
5✔
201
                rest.SendErrorJSON(w, r, log.Default(), http.StatusBadRequest, err, "secrets not configured")
1✔
202
                return
1✔
203
        }
1✔
204
        if errors.Is(err, store.ErrNotFound) {
4✔
205
                rest.SendErrorJSON(w, r, log.Default(), http.StatusNotFound, err, "key not found")
1✔
206
                return
1✔
207
        }
1✔
208
        if err != nil {
2✔
209
                rest.SendErrorJSON(w, r, log.Default(), http.StatusInternalServerError, err, "failed to get key")
×
210
                return
×
211
        }
×
212

213
        log.Printf("[DEBUG] get %s (%d bytes, format=%s)", key, len(value), format)
2✔
214

2✔
215
        w.Header().Set("Content-Type", h.formatToContentType(format))
2✔
216
        w.WriteHeader(http.StatusOK)
2✔
217
        if _, err := w.Write(value); err != nil {
2✔
218
                log.Printf("[WARN] failed to write response: %v", err)
×
219
        }
×
220
}
221

222
// formatToContentType maps storage format to HTTP Content-Type
223
func (h *Handler) formatToContentType(format string) string {
11✔
224
        if f, err := stash.ParseFormat(format); err == nil {
21✔
225
                return f.ContentType()
10✔
226
        }
10✔
227
        return "application/octet-stream"
1✔
228
}
229

230
// handleSet stores a value for a key.
231
// PUT /kv/{key...}
232
// accepts format via X-Stash-Format header or ?format= query param (defaults to "text")
233
func (h *Handler) handleSet(w http.ResponseWriter, r *http.Request) {
9✔
234
        key := store.NormalizeKey(r.PathValue("key"))
9✔
235
        if key == "" {
10✔
236
                rest.SendErrorJSON(w, r, log.Default(), http.StatusBadRequest, nil, "key is required")
1✔
237
                return
1✔
238
        }
1✔
239

240
        value, err := io.ReadAll(r.Body)
8✔
241
        if err != nil {
8✔
242
                rest.SendErrorJSON(w, r, log.Default(), http.StatusBadRequest, err, "failed to read body")
×
243
                return
×
244
        }
×
245

246
        // get format from header or query param, default to "text"
247
        format := r.Header.Get("X-Stash-Format")
8✔
248
        if format == "" {
14✔
249
                format = r.URL.Query().Get("format")
6✔
250
        }
6✔
251
        if !h.formatValidator.IsValidFormat(format) {
14✔
252
                format = "text"
6✔
253
        }
6✔
254

255
        created, err := h.store.Set(r.Context(), key, value, format)
8✔
256
        if err != nil {
11✔
257
                if errors.Is(err, store.ErrSecretsNotConfigured) {
4✔
258
                        rest.SendErrorJSON(w, r, log.Default(), http.StatusBadRequest, err, "secrets not configured")
1✔
259
                        return
1✔
260
                }
1✔
261
                if errors.Is(err, store.ErrInvalidZKPayload) {
3✔
262
                        rest.SendErrorJSON(w, r, log.Default(), http.StatusBadRequest, err, "invalid ZK payload")
1✔
263
                        return
1✔
264
                }
1✔
265
                rest.SendErrorJSON(w, r, log.Default(), http.StatusInternalServerError, err, "failed to set key")
1✔
266
                return
1✔
267
        }
268

269
        operation := "update"
5✔
270
        if created {
10✔
271
                operation = "create"
5✔
272
        }
5✔
273
        log.Printf("[INFO] %s %q (%d bytes, format=%s) by %s", operation, key, len(value), format, h.getIdentityForLog(r))
5✔
274

5✔
275
        // commit to git if enabled
5✔
276
        if h.git != nil {
6✔
277
                req := git.CommitRequest{Key: key, Value: value, Operation: operation, Format: format, Author: h.getAuthorFromRequest(r)}
1✔
278
                if err := h.git.Commit(req); err != nil {
1✔
279
                        log.Printf("[WARN] git commit failed for %s: %v", key, err)
×
280
                }
×
281
        }
282

283
        if created {
10✔
284
                w.WriteHeader(http.StatusCreated)
5✔
285
        } else {
5✔
NEW
286
                w.WriteHeader(http.StatusOK)
×
NEW
287
        }
×
288
}
289

290
// handleDelete removes a key from the store.
291
// DELETE /kv/{key...}
292
func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request) {
5✔
293
        key := store.NormalizeKey(r.PathValue("key"))
5✔
294
        if key == "" {
6✔
295
                rest.SendErrorJSON(w, r, log.Default(), http.StatusBadRequest, nil, "key is required")
1✔
296
                return
1✔
297
        }
1✔
298

299
        err := h.store.Delete(r.Context(), key)
4✔
300
        if errors.Is(err, store.ErrNotFound) {
5✔
301
                rest.SendErrorJSON(w, r, log.Default(), http.StatusNotFound, err, "key not found")
1✔
302
                return
1✔
303
        }
1✔
304
        if err != nil {
4✔
305
                rest.SendErrorJSON(w, r, log.Default(), http.StatusInternalServerError, err, "failed to delete key")
1✔
306
                return
1✔
307
        }
1✔
308

309
        log.Printf("[INFO] delete %q by %s", key, h.getIdentityForLog(r))
2✔
310

2✔
311
        // delete from git if enabled
2✔
312
        if h.git != nil {
3✔
313
                if err := h.git.Delete(key, h.getAuthorFromRequest(r)); err != nil {
1✔
314
                        log.Printf("[WARN] git delete failed for %s: %v", key, err)
×
315
                }
×
316
        }
317

318
        w.WriteHeader(http.StatusNoContent)
2✔
319
}
320

321
// historyResponse represents a single entry in the history response.
322
type historyResponse struct {
323
        Hash      string `json:"hash"`
324
        Timestamp string `json:"timestamp"`
325
        Author    string `json:"author"`
326
        Operation string `json:"operation"`
327
        Format    string `json:"format"`
328
        Value     string `json:"value"` // base64 encoded
329
}
330

331
// handleHistory returns the commit history for a key.
332
// GET /kv/history/{key...}
333
func (h *Handler) handleHistory(w http.ResponseWriter, r *http.Request) {
6✔
334
        if h.git == nil {
7✔
335
                rest.SendErrorJSON(w, r, log.Default(), http.StatusServiceUnavailable, nil, "git integration not enabled")
1✔
336
                return
1✔
337
        }
1✔
338

339
        key := store.NormalizeKey(r.PathValue("key"))
5✔
340
        if key == "" {
6✔
341
                rest.SendErrorJSON(w, r, log.Default(), http.StatusBadRequest, nil, "key is required")
1✔
342
                return
1✔
343
        }
1✔
344

345
        // check read permission
346
        filtered := h.filterKeysByAuth(r, []string{key})
4✔
347
        if len(filtered) == 0 {
5✔
348
                rest.SendErrorJSON(w, r, log.Default(), http.StatusForbidden, nil, "access denied")
1✔
349
                return
1✔
350
        }
1✔
351

352
        history, err := h.git.History(key, 50)
3✔
353
        if err != nil {
4✔
354
                rest.SendErrorJSON(w, r, log.Default(), http.StatusInternalServerError, err, "failed to get history")
1✔
355
                return
1✔
356
        }
1✔
357

358
        // base64-encode values to safely transmit arbitrary binary data in JSON
359
        resp := make([]historyResponse, len(history))
2✔
360
        for i, entry := range history {
4✔
361
                resp[i] = historyResponse{
2✔
362
                        Hash:      entry.Hash,
2✔
363
                        Timestamp: entry.Timestamp.UTC().Format(time.RFC3339),
2✔
364
                        Author:    entry.Author,
2✔
365
                        Operation: entry.Operation,
2✔
366
                        Format:    entry.Format,
2✔
367
                        Value:     base64.StdEncoding.EncodeToString(entry.Value),
2✔
368
                }
2✔
369
        }
2✔
370

371
        rest.RenderJSON(w, resp)
2✔
372
}
373

374
// identityType represents the type of identity detected from a request.
375
type identityType int
376

377
const (
378
        identityAnonymous identityType = iota
379
        identityUser
380
        identityToken
381
)
382

383
// identity holds information about who made a request.
384
type identity struct {
385
        typ  identityType
386
        name string // username or token prefix
387
}
388

389
// getIdentity extracts identity from request context.
390
// returns user identity from session cookie, token identity from Authorization header, or anonymous.
391
func (h *Handler) getIdentity(r *http.Request) identity {
17✔
392
        if h.auth == nil {
19✔
393
                return identity{typ: identityAnonymous}
2✔
394
        }
2✔
395

396
        // check session cookie for web UI users
397
        for _, cookieName := range cookie.SessionCookieNames {
45✔
398
                c, err := r.Cookie(cookieName)
30✔
399
                if err != nil {
57✔
400
                        continue
27✔
401
                }
402
                if username, valid := h.auth.GetSessionUser(r.Context(), c.Value); valid && username != "" {
6✔
403
                        return identity{typ: identityUser, name: username}
3✔
404
                }
3✔
405
        }
406

407
        // check API token from Authorization header
408
        if authHeader := r.Header.Get("Authorization"); strings.HasPrefix(authHeader, "Bearer ") {
14✔
409
                token := strings.TrimPrefix(authHeader, "Bearer ")
2✔
410
                if h.auth.HasTokenACL(token) {
4✔
411
                        prefix := token
2✔
412
                        if len(prefix) > 8 {
4✔
413
                                prefix = prefix[:8]
2✔
414
                        }
2✔
415
                        return identity{typ: identityToken, name: "token:" + prefix}
2✔
416
                }
417
        }
418

419
        return identity{typ: identityAnonymous}
10✔
420
}
421

422
// getAuthorFromRequest extracts the git author from request context.
423
// returns username from session cookie for web UI users, token prefix for API tokens, default author otherwise.
424
func (h *Handler) getAuthorFromRequest(r *http.Request) git.Author {
5✔
425
        id := h.getIdentity(r)
5✔
426
        switch id.typ {
5✔
427
        case identityUser, identityToken:
2✔
428
                return git.Author{Name: id.name, Email: id.name + "@stash"}
2✔
429
        default:
3✔
430
                return git.DefaultAuthor()
3✔
431
        }
432
}
433

434
// getIdentityForLog returns identity string for audit logging.
435
// returns "user:xxx" for web UI users, "token:xxx" for API tokens, "anonymous" otherwise.
436
func (h *Handler) getIdentityForLog(r *http.Request) string {
9✔
437
        id := h.getIdentity(r)
9✔
438
        switch id.typ {
9✔
439
        case identityUser:
1✔
440
                return "user:" + id.name
1✔
441
        case identityToken:
×
442
                return id.name // already has "token:" prefix
×
443
        default:
8✔
444
                return "anonymous"
8✔
445
        }
446
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc