fix(securityaudit): log prompt_guard.config_loaded only on change

ConfigManager.refreshLoop reloads the Prompt Guard config every 5s and
Reload logged config_loaded on every successful load, so an unchanged
config produced up to ~17k identical lines per instance per day and
buried real configuration changes.

Log the event only when the reload carries news: the first snapshot, a
new config version (every admin save bumps it under the advisory lock in
UpdateConfig), a flip of the global risk control gate (a separate setting
that leaves the version untouched), or a recovery from a failed reload so
the degraded-to-healthy transition stays visible.

This mirrors logInvalidTokenEndpoints, which already warns once per
change rather than on every refresh.
This commit is contained in:
Yoga Sakti
2026-08-21 15:51:11 +07:00
parent 3548256745
commit f6aa9dc3c8
2 changed files with 73 additions and 5 deletions
@@ -136,14 +136,28 @@ func (m *ConfigManager) Reload(ctx context.Context) error {
previous := m.snapshot.Load()
m.snapshot.Store(&activeConfigSnapshot{storage: cloneStorageConfig(storage), active: cloneActiveConfig(active), loadedAt: now})
m.configUntrusted.Store(false)
m.clearLoadError()
recovered := m.clearLoadError()
m.logInvalidTokenEndpoints(previous, active)
LogInfo(EventConfigLoaded, map[string]any{
"config_version": storage.ConfigVersion, "status": "loaded",
})
// refreshLoop calls Reload every 5s, so logging every successful load turns
// config_loaded into a heartbeat that buries real config changes.
if recovered || shouldLogConfigLoaded(previous, storage, active) {
LogInfo(EventConfigLoaded, map[string]any{
"config_version": storage.ConfigVersion, "status": "loaded",
})
}
return nil
}
// shouldLogConfigLoaded reports whether a successful reload carries news: the
// first snapshot, a new config version (every admin save bumps it under the
// advisory lock in UpdateConfig) or a flip of the global risk control gate,
// which lives in its own setting and so leaves the version untouched.
func shouldLogConfigLoaded(previous *activeConfigSnapshot, storage storageConfig, active ActiveConfig) bool {
return previous == nil ||
previous.storage.ConfigVersion != storage.ConfigVersion ||
previous.active.RiskControlEnabled != active.RiskControlEnabled
}
// logInvalidTokenEndpoints warns once per change (not on every 5s refresh)
// when stored endpoint tokens cannot be decrypted with the current key.
func (m *ConfigManager) logInvalidTokenEndpoints(previous *activeConfigSnapshot, active ActiveConfig) {
@@ -490,11 +504,15 @@ func (m *ConfigManager) recordLoadError(_ error) {
m.stateMu.Unlock()
}
func (m *ConfigManager) clearLoadError() {
// clearLoadError drops the recorded load failure and reports whether one was
// pending, so callers can tell a recovery apart from an unchanged reload.
func (m *ConfigManager) clearLoadError() bool {
m.stateMu.Lock()
recovered := m.lastLoadError != ""
m.lastLoadError = ""
m.lastErrorAt = nil
m.stateMu.Unlock()
return recovered
}
func cloneStorageConfig(cfg storageConfig) storageConfig {
@@ -1,9 +1,11 @@
package securityaudit
import (
"bytes"
"context"
"encoding/json"
"errors"
"log/slog"
"strings"
"testing"
@@ -454,3 +456,51 @@ func TestUpdateConfigStrictBoundsAndKnownValues(t *testing.T) {
})
}
}
// Regression coverage for issue #5732: refreshLoop reloads every 5s, so
// config_loaded must stay a change signal instead of ~17k identical lines a
// day, while still reporting the first load, real config changes and a
// recovery from a failed reload.
func TestConfigLoadedIsLoggedOnlyWhenSomethingChanged(t *testing.T) {
storage := DefaultStorageConfig()
storage.ConfigVersion = 4
raw, err := json.Marshal(storage)
require.NoError(t, err)
repository := &switchableSettingRepository{staticSettingRepository: staticSettingRepository{values: map[string]string{
SettingKeyPromptAuditConfig: string(raw),
SettingKeyRiskControl: "false",
}}}
manager := NewConfigManager(nil, repository, nil, prefixEncryptor{}, testTotpKeyConfig())
var output bytes.Buffer
previous := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&output, nil)))
t.Cleanup(func() { slog.SetDefault(previous) })
loadedCount := func() int { return strings.Count(output.String(), EventConfigLoaded) }
require.NoError(t, manager.Reload(context.Background()))
require.Equal(t, 1, loadedCount(), "the first successful load must be logged")
require.NoError(t, manager.Reload(context.Background()))
require.NoError(t, manager.Reload(context.Background()))
require.Equal(t, 1, loadedCount(), "TTL refreshes of an unchanged config must stay silent")
repository.values[SettingKeyRiskControl] = "true"
require.NoError(t, manager.Reload(context.Background()))
require.Equal(t, 2, loadedCount(), "flipping the global risk control gate must be logged")
storage.ConfigVersion = 5
raw, err = json.Marshal(storage)
require.NoError(t, err)
repository.values[SettingKeyPromptAuditConfig] = string(raw)
require.NoError(t, manager.Reload(context.Background()))
require.Equal(t, 3, loadedCount(), "a new config version must be logged")
repository.loadErr = errors.New("settings unavailable")
require.Error(t, manager.Reload(context.Background()))
require.Equal(t, 3, loadedCount(), "a failed reload must not claim a load")
repository.loadErr = nil
require.NoError(t, manager.Reload(context.Background()))
require.Equal(t, 4, loadedCount(), "recovering from a failed reload must be visible")
}