Skip to content

Commit ef8d1c7

Browse files
authored
Merge pull request #711 from d3v-active/feat/flag-audit-trail
feat: audit trail for feature flag changes
2 parents b7d745b + 70106c0 commit ef8d1c7

4 files changed

Lines changed: 109 additions & 3 deletions

File tree

internal/featureflags/featureflags.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ type Flag struct {
1515
Enabled bool `json:"enabled"`
1616
Description string `json:"description"`
1717
UpdatedAt time.Time `json:"updated_at"`
18+
Version int64 `json:"version"`
1819
}
1920

2021
type Manager struct {
@@ -79,6 +80,7 @@ func (m *Manager) LoadDefaultFlags() {
7980
Enabled: false,
8081
Description: "Enable fault injection middleware for resilience testing",
8182
UpdatedAt: time.Now(),
83+
Version: time.Now().UnixNano(),
8284
},
8385
}
8486

@@ -103,6 +105,7 @@ func (m *Manager) LoadFromEnvironment() {
103105
Enabled: enabled,
104106
Description: "Environment-defined flag",
105107
UpdatedAt: time.Now(),
108+
Version: time.Now().UnixNano(),
106109
}
107110
}
108111
m.mutex.Unlock()
@@ -133,6 +136,7 @@ func (m *Manager) LoadFromEnvironment() {
133136
Enabled: enabled,
134137
Description: "Environment flag",
135138
UpdatedAt: time.Now(),
139+
Version: time.Now().UnixNano(),
136140
}
137141
}
138142
m.mutex.Unlock()
@@ -212,22 +216,34 @@ func (m *Manager) GetFlag(flagName string) (*Flag, bool) {
212216
}
213217

214218
func (m *Manager) SetFlag(flagName string, enabled bool, description string) {
219+
m.SetFlagWithVersion(flagName, enabled, description, time.Now().UnixNano())
220+
}
221+
222+
func (m *Manager) SetFlagWithVersion(flagName string, enabled bool, description string, version int64) bool {
215223
m.mutex.Lock()
216224
defer m.mutex.Unlock()
217225

218226
if flag, exists := m.flags[flagName]; exists {
227+
if version <= flag.Version {
228+
// Monotonic version check: last writer wins. If version is older, reject.
229+
return false
230+
}
219231
flag.Enabled = enabled
220232
flag.UpdatedAt = time.Now()
233+
flag.Version = version
221234
if description != "" {
222235
flag.Description = description
223236
}
237+
return true
224238
} else {
225239
m.flags[flagName] = &Flag{
226240
Name: flagName,
227241
Enabled: enabled,
228242
Description: description,
229243
UpdatedAt: time.Now(),
244+
Version: version,
230245
}
246+
return true
231247
}
232248
}
233249

internal/featureflags/featureflags_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,3 +228,36 @@ func TestSafeFlagProtection(t *testing.T) {
228228
t.Error("Critical flag should not be disabled")
229229
}
230230
}
231+
232+
func TestSetFlagWithVersion_Monotonic(t *testing.T) {
233+
manager := GetInstance()
234+
235+
// Initial set
236+
success := manager.SetFlagWithVersion("monotonic_test", true, "Initial", 100)
237+
if !success {
238+
t.Error("Expected initial set to succeed")
239+
}
240+
241+
// Try to set with older version
242+
success = manager.SetFlagWithVersion("monotonic_test", false, "Older", 50)
243+
if success {
244+
t.Error("Expected set with older version to fail")
245+
}
246+
247+
// Try to set with same version
248+
success = manager.SetFlagWithVersion("monotonic_test", false, "Same", 100)
249+
if success {
250+
t.Error("Expected set with same version to fail")
251+
}
252+
253+
// Try to set with newer version
254+
success = manager.SetFlagWithVersion("monotonic_test", false, "Newer", 150)
255+
if !success {
256+
t.Error("Expected set with newer version to succeed")
257+
}
258+
259+
flag, exists := manager.GetFlag("monotonic_test")
260+
if !exists || flag.Enabled {
261+
t.Error("Flag should be disabled now")
262+
}
263+
}

internal/handlers/feature_flags.go

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package handlers
22

33
import (
44
"net/http"
5+
"strings"
6+
"time"
57
"stellarbill-backend/internal/audit"
68
"stellarbill-backend/internal/featureflags"
79

@@ -50,15 +52,38 @@ func (h *FeatureFlagsHandler) ToggleFeatureFlag(c *gin.Context) {
5052

5153
// Toggle and update flag
5254
afterEnabled := !beforeEnabled
53-
h.flagManager.SetFlag(req.Name, afterEnabled, flag.Description)
55+
newVersion := time.Now().UnixNano()
56+
57+
success := h.flagManager.SetFlagWithVersion(req.Name, afterEnabled, flag.Description, newVersion)
58+
if !success {
59+
RespondWithError(c, http.StatusConflict, ErrorCodeConflict, "concurrent modification: flag was updated by another request")
60+
return
61+
}
5462

5563
// Get updated flag
5664
updatedFlag, _ := h.flagManager.GetFlag(req.Name)
5765

66+
isSensitive := false
67+
lowerName := strings.ToLower(req.Name)
68+
sensitiveKeys := []string{"secret", "token", "password", "key", "auth", "cvv", "card"}
69+
for _, sk := range sensitiveKeys {
70+
if strings.Contains(lowerName, sk) {
71+
isSensitive = true
72+
break
73+
}
74+
}
75+
76+
beforeStr := boolToString(beforeEnabled)
77+
afterStr := boolToString(afterEnabled)
78+
if isSensitive {
79+
beforeStr = "[REDACTED]"
80+
afterStr = "[REDACTED]"
81+
}
82+
5883
// Log audit action (failure doesn't block success)
5984
audit.LogAction(c, "feature_flag_toggle", req.Name, "success", map[string]string{
60-
"before_enabled": boolToString(beforeEnabled),
61-
"after_enabled": boolToString(afterEnabled),
85+
"before_enabled": beforeStr,
86+
"after_enabled": afterStr,
6287
"reason": req.Reason,
6388
})
6489

internal/worker/audit_exporter.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,17 @@ import (
1616
// AuditExporterJob handles the nightly export of audit logs to a data warehouse.
1717
// In this implementation, it exports to a local staging file which a data warehouse
1818
// ingestion tool (like Fluentd, Logstash, or a cron script) will pick up.
19+
//
20+
// Export Schema (JSONL):
21+
// - timestamp: ISO8601 string of the event time
22+
// - actor: ID or IP of the user who performed the action
23+
// - action: "feature_flag_toggle"
24+
// - resource: The name of the feature flag
25+
// - outcome: "success" or "failure"
26+
// - reason: The reason provided for the toggle
27+
// - before_enable: The state of the flag before the toggle (or [REDACTED])
28+
// - after_enable: The state of the flag after the toggle (or [REDACTED])
29+
// - hash: Cryptographic hash of the event
1930
type AuditExporterJob struct {
2031
auditLogPath string
2132
exportFilePath string
@@ -107,3 +118,24 @@ func (j *AuditExporterJob) Run(ctx context.Context) error {
107118
security.ProductionLogger().Info("Audit log export completed", zap.Int("exported_count", exportedCount))
108119
return nil
109120
}
121+
122+
// Start begins the ticker for the nightly export process.
123+
func (j *AuditExporterJob) Start(ctx context.Context, interval time.Duration) {
124+
if interval <= 0 {
125+
interval = 24 * time.Hour
126+
}
127+
ticker := time.NewTicker(interval)
128+
defer ticker.Stop()
129+
130+
for {
131+
select {
132+
case <-ctx.Done():
133+
return
134+
case <-ticker.C:
135+
if err := j.Run(ctx); err != nil {
136+
security.ProductionLogger().Error("Nightly audit export failed", zap.Error(err))
137+
}
138+
}
139+
}
140+
}
141+

0 commit comments

Comments
 (0)