Skip to content

Commit 5ac4657

Browse files
authored
Merge pull request #813 from humblezdan/feat/wire-audit-middleware
feat: install audit middleware and emit admin action events
2 parents f217a71 + 2c3efea commit 5ac4657

6 files changed

Lines changed: 52 additions & 8 deletions

File tree

internal/audit/exporter_test.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package audit
33
import (
44
"context"
55
"errors"
6+
"fmt"
67
"sync"
78
"testing"
89
"time"
@@ -67,7 +68,7 @@ func TestWORMExporter_RotationBySize(t *testing.T) {
6768
err = exporter.WriteEvent(e2)
6869
require.NoError(t, err)
6970
assert.True(t, uploaded)
70-
71+
7172
// Buffer should be empty now
7273
exporter.mu.Lock()
7374
assert.Equal(t, 0, len(exporter.buffer))
@@ -159,7 +160,7 @@ func TestWORMExporter_ConcurrentWritesAndRotations(t *testing.T) {
159160
// 5 concurrent routines writing 20 events each = 100 events total. Should trigger 2 rotations.
160161
// But wait! Hash chaining requires strictly sequential hashing. We can't generate sequential events concurrently easily.
161162
// We will serialize event generation, but test that `Rotate` doesn't block `WriteEvent`.
162-
163+
163164
events := make([]AuditEvent, 100)
164165
var prev string
165166
for i := 0; i < 100; i++ {
@@ -174,7 +175,7 @@ func TestWORMExporter_ConcurrentWritesAndRotations(t *testing.T) {
174175
_ = exporter.WriteEvent(ev) // Order doesn't actually matter for WriteEvent thread safety, but the verifier requires sequential order!
175176
}(events[i])
176177
}
177-
178+
178179
// Wait, if they are written out of order, the chain verification will FAIL and panic!
179180
// So we can't test concurrent writes this way.
180181
}
@@ -208,7 +209,7 @@ func TestWORMExporter_ConcurrentWritesAndRotations_Fixed(t *testing.T) {
208209
for i := 0; i < 49; i++ {
209210
_ = exporter.WriteEvent(events[i])
210211
}
211-
212+
212213
wg.Add(1)
213214
go func() {
214215
defer wg.Done()

internal/audit/logger.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"encoding/hex"
88
"errors"
99
"fmt"
10+
"os"
1011
"strings"
1112
"sync"
1213
"time"
@@ -50,6 +51,15 @@ func NewLogger(secret string, sink Sink) *Logger {
5051
}
5152
}
5253

54+
// NewSinkFromEnv creates a sink from AUDIT_LOG_PATH, falling back to stderr.
55+
func NewSinkFromEnv() Sink {
56+
path := strings.TrimSpace(os.Getenv("AUDIT_LOG_PATH"))
57+
if path == "" {
58+
return NewStderrSink()
59+
}
60+
return NewFileSink(path)
61+
}
62+
5363
func (l *Logger) Log(ctx context.Context, event AuditEvent) (AuditEvent, error) {
5464
if l == nil {
5565
return AuditEvent{}, errors.New("audit logger is not initialized")

internal/audit/sink.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,23 @@ func (s *FileSink) WriteEvent(e AuditEvent) error {
3838
return err
3939
}
4040

41+
// StderrSink writes JSONL audit entries to stderr for environments without a configured file path.
42+
type StderrSink struct{}
43+
44+
// NewStderrSink returns a sink that writes JSONL data to stderr.
45+
func NewStderrSink() *StderrSink {
46+
return &StderrSink{}
47+
}
48+
49+
func (s *StderrSink) WriteEvent(e AuditEvent) error {
50+
encoded, err := json.Marshal(e)
51+
if err != nil {
52+
return err
53+
}
54+
_, err = os.Stderr.Write(append(encoded, '\n'))
55+
return err
56+
}
57+
4158
// MemorySink keeps audit entries in-memory, intended for tests.
4259
type MemorySink struct {
4360
mu sync.Mutex

internal/handlers/admin.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
// Package handlers implements HTTP request handlers for the Stellabill API.
22
//
3-
// Admin login lockout
3+
// # Admin login lockout
44
//
55
// The AdminHandler.Login endpoint uses an exponential backoff lockout to
66
// rate-limit failed admin authentication attempts. Each failure for a given
77
// source IP + account name doubles the lockout duration from 1s up to a
88
// maximum of 15 minutes.
99
//
10-
// Lockout reset
10+
// # Lockout reset
1111
//
1212
// A successful login for the key (source, account) immediately clears its
1313
// lockout state via LockoutTracker.Reset. Operators can also force a reset
@@ -123,10 +123,10 @@ func (h *AdminHandler) extractCredentialsFromHeaders(c *gin.Context) AdminLoginR
123123
// PurgeCache handles cache purge requests.
124124
func (h *AdminHandler) PurgeCache(c *gin.Context) {
125125
if token := c.GetHeader("X-Admin-Token"); token == "" || token != h.expectedToken {
126+
audit.LogAction(c, "admin_purge", "cache", "denied", map[string]string{"reason": "invalid_token"})
126127
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
127128
return
128129
}
130+
audit.LogAction(c, "admin_purge", "cache", "success", map[string]string{"status": "purged"})
129131
c.JSON(http.StatusOK, gin.H{"status": "purged"})
130132
}
131-
132-

internal/handlers/reconciliation.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,22 @@ func NewReconcileHandler(adapter reconciliation.Adapter, store reconciliation.St
1818
return func(c *gin.Context) {
1919
callerID, exists := c.Get("callerID")
2020
if !exists {
21+
audit.LogAction(c, "reconciliation.execute", "reconciliation", "denied", map[string]string{"reason": "missing_auth"})
2122
RespondWithAuthError(c, "Missing authentication credentials")
2223
return
2324
}
2425

2526
tenantID, exists := c.Get("tenantID")
2627
if !exists {
28+
audit.LogAction(c, "reconciliation.execute", "reconciliation", "denied", map[string]string{"reason": "missing_tenant"})
2729
RespondWithAuthError(c, "Missing tenant context")
2830
return
2931
}
3032
tid := tenantID.(string)
3133

3234
roles := auth.ExtractRoles(c)
3335
if !hasAnyPermission(roles, auth.PermManageReconciliation) {
36+
audit.LogAction(c, "reconciliation.execute", "reconciliation", "denied", map[string]string{"reason": "insufficient_permissions"})
3437
RespondWithError(c, http.StatusForbidden, ErrorCodeForbidden, "Insufficient permissions for reconciliation")
3538
return
3639
}
@@ -40,6 +43,7 @@ func NewReconcileHandler(adapter reconciliation.Adapter, store reconciliation.St
4043

4144
var backendSubs []reconciliation.BackendSubscription
4245
if err := c.ShouldBindJSON(&backendSubs); err != nil {
46+
audit.LogAction(c, "reconciliation.execute", "reconciliation", "denied", map[string]string{"reason": "invalid_request"})
4347
RespondWithValidationError(c, "Invalid request body", map[string]interface{}{
4448
"reason": err.Error(),
4549
})
@@ -50,6 +54,7 @@ func NewReconcileHandler(adapter reconciliation.Adapter, store reconciliation.St
5054
if !isAdmin {
5155
for _, b := range backendSubs {
5256
if b.TenantID != "" && b.TenantID != tid {
57+
audit.LogAction(c, "reconciliation.execute", "reconciliation", "denied", map[string]string{"reason": "cross_tenant"})
5358
RespondWithError(c, http.StatusForbidden, ErrorCodeForbidden,
5459
"Cannot reconcile subscriptions belonging to another tenant")
5560
return
@@ -97,6 +102,12 @@ func NewReconcileHandler(adapter reconciliation.Adapter, store reconciliation.St
97102
}
98103
}
99104

105+
audit.LogAction(c, "reconciliation.execute", "reconciliation", "success", map[string]string{
106+
"total": strconv.Itoa(len(reports)),
107+
"matched": strconv.Itoa(matched),
108+
"mismatched": strconv.Itoa(len(reports) - matched),
109+
})
110+
100111
c.JSON(http.StatusOK, gin.H{
101112
"summary": gin.H{"total": len(reports), "matched": matched, "mismatched": len(reports) - matched},
102113
"reports": reports,

internal/routes/routes.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"os"
88
"time"
99

10+
"stellarbill-backend/internal/audit"
1011
"stellarbill-backend/internal/auth"
1112
"stellarbill-backend/internal/config"
1213
"stellarbill-backend/internal/db"
@@ -93,6 +94,10 @@ func Register(r *gin.Engine) {
9394
subRepo := repository.NewMockSubscriptionRepo()
9495
planRepo := repository.NewMockPlanRepo()
9596
stmtRepo := repository.NewMockStatementRepo()
97+
auditLogger := audit.NewLogger(cfg.JWTSecret, audit.NewSinkFromEnv())
98+
if auditLogger != nil {
99+
r.Use(audit.Middleware(auditLogger))
100+
}
96101

97102
r.Use(middleware.DataLoaderMiddleware(planRepo, subRepo))
98103

0 commit comments

Comments
 (0)