fix(security-audit): 仅在 blocking 意图下 fail-closed,修复无法关闭审计
configUntrusted 不再单独强制 ModeBlocking,避免默认关闭部署在配置 加载失败时对全部请求返回 prompt_guard_unavailable;成功保存配置后 清除 untrusted,确保管理员关闭提示词审计能立即生效。 Fixes #4560
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user