fix(security-audit): 仅在 blocking 意图下 fail-closed,修复无法关闭审计

configUntrusted 不再单独强制 ModeBlocking,避免默认关闭部署在配置
加载失败时对全部请求返回 prompt_guard_unavailable;成功保存配置后
清除 untrusted,确保管理员关闭提示词审计能立即生效。

Fixes #4560
This commit is contained in:
abbzbb
2026-07-18 23:57:45 +08:00
parent d4b9797ff7
commit 62846bb074
4 changed files with 94 additions and 12 deletions
+5 -4
View File
@@ -155,10 +155,11 @@ func runMainServer() {
defer app.Cleanup()
if app.PromptAudit != nil {
if err := app.PromptAudit.Start(context.Background()); err != nil {
// Startup continues so unrelated APIs stay up, but Prompt Audit itself
// fails closed (unavailable) until a later reload installs a trusted
// snapshot—avoiding a silent ModeOff bypass of persisted blocking policy.
log.Printf("Prompt Audit started in degraded fail-closed state: %v", err)
// Startup continues so unrelated APIs stay up. Fail-closed (unavailable)
// applies only when a persisted blocking policy was observed; without
// blocking intent, Prompt Audit stays ModeOff so the gateway remains
// usable and administrators can still disable the feature (#4560).
log.Printf("Prompt Audit started in degraded state: %v", err)
}
}
@@ -41,6 +41,8 @@ type ConfigStore interface {
EffectiveMode() Mode
// BlockingActivationDegraded is true when storage intent requires blocking
// but no usable blocking snapshot is active (cold start or failed reload).
// It must stay false when blocking is not intended, even if config is
// untrusted—otherwise default-off deployments fail closed for all traffic.
BlockingActivationDegraded() bool
Public() PublicConfig
Save(ctx context.Context, req UpdateConfigRequest, actorID int64) (PublicConfig, error)
@@ -37,9 +37,11 @@ type ConfigManager struct {
// activated. A config version alone cannot distinguish async from blocking.
expectedBlocking atomic.Bool
// configUntrusted is set when a load/reload fails before a trustworthy
// snapshot is installed. While set, EffectiveMode fails closed so a
// persisted blocking policy cannot be silently skipped after startup or
// invalidation errors.
// snapshot is installed. Combined with expectedBlocking, EffectiveMode
// fails closed so a persisted blocking policy cannot be silently skipped
// after startup or invalidation errors. Without blocking intent, untrusted
// alone must not force ModeBlocking—Prompt Audit is default-off and must
// not take the gateway down for every API request (see issue #4560).
configUntrusted atomic.Bool
stateMu sync.RWMutex
@@ -147,12 +149,15 @@ func (m *ConfigManager) BlockingActivationDegraded() bool {
if m == nil {
return false
}
if m.configUntrusted.Load() {
return true
}
// Fail closed only when storage intent requires blocking. Untrusted config
// without blocking intent must remain ModeOff so administrators can still
// operate the gateway and turn Prompt Audit off after a failed reload.
if !m.expectedBlocking.Load() {
return false
}
if m.configUntrusted.Load() {
return true
}
active, ok := m.Active()
if !ok {
return true
@@ -264,6 +269,9 @@ func (m *ConfigManager) Save(ctx context.Context, req UpdateConfigRequest, actor
m.expected.Store(next.ConfigVersion)
m.expectedBlocking.Store(active.RiskControlEnabled && next.Enabled && next.BlockingEnabled)
m.snapshot.Store(&activeConfigSnapshot{storage: cloneStorageConfig(next), active: cloneActiveConfig(active), loadedAt: m.clock.Now()})
// A successful admin save installs a trustworthy snapshot; clear any prior
// fail-closed degradation so disabling audit actually takes effect.
m.configUntrusted.Store(false)
m.clearLoadError()
LogInfo(EventConfigUpdated, map[string]any{
"config_version": next.ConfigVersion, "status": "updated",
@@ -137,14 +137,85 @@ func (errorSettingRepository) GetMultiple(context.Context, []string) (map[string
return nil, errors.New("settings unavailable")
}
func TestConfigManagerStartupLoadFailureFailsClosedWithoutSnapshot(t *testing.T) {
func TestConfigManagerStartupLoadFailureDoesNotBlockWhenBlockingNotIntended(t *testing.T) {
// Settings unavailable and no prior blocking intent: stay ModeOff so the
// gateway remains usable and admins can still disable/configure Prompt Audit.
manager := NewConfigManager(nil, errorSettingRepository{}, nil, prefixEncryptor{})
err := manager.Start(context.Background())
require.Error(t, err)
require.True(t, manager.configUntrusted.Load())
require.False(t, manager.BlockingActivationDegraded())
require.Equal(t, ModeOff, manager.EffectiveMode())
service := &PromptService{config: manager, evaluator: NewGuardEvaluator(nil, nil, nil)}
decision, evalErr := service.Evaluate(context.Background(), Request{
Protocol: "openai_chat_completions",
Body: []byte(`{"messages":[{"role":"user","content":"hi"}]}`),
})
require.NoError(t, evalErr)
require.NotNil(t, decision)
require.Equal(t, DecisionAllow, decision.Kind)
require.NoError(t, manager.Shutdown(context.Background()))
}
func TestConfigManagerStartupLoadFailureFailsClosedWhenBlockingIntended(t *testing.T) {
manager := NewConfigManager(nil, errorSettingRepository{}, nil, prefixEncryptor{})
// Simulate intent observed before a later load failure (e.g. decrypt error).
manager.observeExpectedState(`{"enabled":true,"blocking_enabled":true,"config_version":3}`, true)
manager.markConfigUntrusted()
require.True(t, manager.BlockingActivationDegraded())
require.Equal(t, ModeBlocking, manager.EffectiveMode())
require.NoError(t, manager.Shutdown(context.Background()))
service := &PromptService{config: manager, evaluator: NewGuardEvaluator(nil, nil, nil)}
decision, err := service.Evaluate(context.Background(), Request{
Protocol: "openai_chat_completions",
Body: []byte(`{"messages":[{"role":"user","content":"hi"}]}`),
})
require.Error(t, err)
require.Nil(t, decision)
var guardErr *GuardError
require.ErrorAs(t, err, &guardErr)
require.Equal(t, ErrorCodeUnavailable, guardErr.Code)
}
func TestConfigManagerUntrustedClearsOnSuccessfulDisable(t *testing.T) {
// After a degraded fail-closed period, saving disabled config must restore ModeOff.
manager := &ConfigManager{encryptor: prefixEncryptor{}, clock: fixedClock{}}
manager.observeExpectedState(`{"enabled":true,"blocking_enabled":true,"config_version":5}`, true)
manager.markConfigUntrusted()
require.Equal(t, ModeBlocking, manager.EffectiveMode())
// Install a trusted disabled snapshot the same way Save does after commit.
disabled := DefaultStorageConfig()
disabled.ConfigVersion = 6
disabled.Enabled = false
disabled.BlockingEnabled = false
active, err := ActiveFromStorage(disabled, true, manager.encryptor)
require.NoError(t, err)
manager.expected.Store(disabled.ConfigVersion)
manager.expectedBlocking.Store(false)
manager.snapshot.Store(&activeConfigSnapshot{storage: disabled, active: active, loadedAt: manager.clock.Now()})
manager.configUntrusted.Store(false)
require.False(t, manager.BlockingActivationDegraded())
require.Equal(t, ModeOff, manager.EffectiveMode())
service := &PromptService{config: manager, evaluator: NewGuardEvaluator(nil, nil, nil)}
decision, evalErr := service.Evaluate(context.Background(), Request{
Protocol: "openai_chat_completions",
Body: []byte(`{"messages":[{"role":"user","content":"hi"}]}`),
})
require.NoError(t, evalErr)
require.Equal(t, DecisionAllow, decision.Kind)
}
func TestConfigManagerUntrustedWithoutBlockingDoesNotForceBlockingMode(t *testing.T) {
manager := &ConfigManager{}
manager.observeExpectedState(`{"enabled":true,"blocking_enabled":false,"config_version":2}`, true)
manager.markConfigUntrusted()
require.False(t, manager.expectedBlocking.Load())
require.False(t, manager.BlockingActivationDegraded())
require.Equal(t, ModeOff, manager.EffectiveMode(), "async intent + untrusted must not force blocking unavailable")
}
func TestParseLegacyConfigDefaultsMissingFieldsWithoutEnablingBlocking(t *testing.T) {