fix(security-audit): 解密失败不再吞掉整份配置,修复升级后配置消失且无法保存的死锁 (#4887)

根因:prompt audit 是共享 TOTP_ENCRYPTION_KEY 加密器的功能中唯一不校验
EncryptionKeyConfigured 的落点。未配置固定密钥的部署每次重启自动生成新
密钥,v162 保存的节点 Token 密文在升级重启后永久无法解密,Reload 中
ActiveFromStorage 整体失败导致快照永远装不上:管理端 GET 回退默认 v1
(v166 起为 503),而保存路径直读数据库做 CAS 版本对比,必然冲突——
配置既看不见也改不掉。PR #4893 仅改变了报错形态,未修复根因。

修复:
- ActiveFromStorage 对单节点解密失败降级容忍:该节点运行时禁用并标记
  TokenInvalid,配置整体照常激活;管理端恢复显示真实版本号,重新输入
  Token 即可自愈(密文保留,密钥恢复后自动复原)
- blocking 意图下零可用节点时 evaluator 仍返回 unavailable,请求照旧
  被拒,fail-closed 语义不回归;async 意图下 enqueue 直接 drop 并告警
- Save 在未配置固定加密密钥时拒绝保存新 Token(与 TOTP/Ollama/备份
  一致的门控),错误码 prompt_audit_encryption_key_required
- token_status 新增 invalid 状态,前端凭据列与编辑框提示重新输入
- 新增 config_token_invalid 告警日志(集合变化时记录一次,不随 5s
  刷新刷屏)
This commit is contained in:
shaw
2026-07-28 09:31:36 +08:00
parent 59ce11c780
commit bfbe113f5e
15 changed files with 244 additions and 42 deletions
+1 -1
View File
@@ -255,7 +255,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
contentModerationHashCache := repository.NewContentModerationHashCache(redisClient)
contentModerationService := service.NewContentModerationService(settingRepository, contentModerationRepository, contentModerationHashCache, groupRepository, userRepository, apiKeyAuthCacheInvalidator, emailService)
contentModerationHandler := admin.NewContentModerationHandler(contentModerationService)
configManager := securityaudit.NewConfigManager(db, settingRepository, redisClient, secretEncryptor)
configManager := securityaudit.NewConfigManager(db, settingRepository, redisClient, secretEncryptor, configConfig)
postgreSQLRepository := securityaudit.NewPostgreSQLRepository(db)
redisPayloadStore := securityaudit.NewRedisPayloadStore(redisClient)
openAICompatibleScanner := securityaudit.NewOpenAICompatibleScanner()
@@ -90,6 +90,11 @@ type ActiveEndpoint struct {
TimeoutMS int
InputLimit int
Enabled bool
// TokenInvalid marks an endpoint whose persisted token ciphertext cannot be
// decrypted with the current encryption key (key changed or auto-generated
// on restart). The endpoint is kept visible for admins but excluded from
// runtime use until the token is re-entered or cleared (issue #4887).
TokenInvalid bool
}
type ActiveConfig struct {
@@ -365,7 +370,23 @@ func (cfg ActiveConfig) EnabledEndpoints() []ActiveEndpoint {
return result
}
func PublicFromStorage(cfg storageConfig, riskControlEnabled bool) PublicConfig {
// InvalidTokenEndpointIDs lists endpoints whose stored token could not be
// decrypted with the current encryption key.
func (cfg ActiveConfig) InvalidTokenEndpointIDs() []string {
ids := make([]string, 0)
for _, ep := range cfg.Endpoints {
if ep.TokenInvalid {
ids = append(ids, ep.ID)
}
}
return ids
}
func PublicFromStorage(cfg storageConfig, riskControlEnabled bool, invalidTokenEndpointIDs []string) PublicConfig {
invalid := make(map[string]struct{}, len(invalidTokenEndpointIDs))
for _, id := range invalidTokenEndpointIDs {
invalid[id] = struct{}{}
}
scanners := append([]string{}, cfg.Scanners...)
groupIDs := append([]int64{}, cfg.GroupIDs...)
endpoints := make([]PublicEndpoint, 0, len(cfg.Endpoints))
@@ -374,6 +395,9 @@ func PublicFromStorage(cfg storageConfig, riskControlEnabled bool) PublicConfig
status := "missing"
if hasToken {
status = "configured"
if _, ok := invalid[ep.ID]; ok {
status = "invalid"
}
}
endpoints = append(endpoints, PublicEndpoint{
ID: ep.ID, Name: ep.Name, Protocol: ep.Protocol, BaseURL: ep.BaseURL,
@@ -402,19 +426,27 @@ func ActiveFromStorage(cfg storageConfig, riskControlEnabled bool, encryptor Sec
}
for _, ep := range cfg.Endpoints {
token := ""
tokenInvalid := false
if ep.TokenCiphertext != "" {
if encryptor == nil {
return ActiveConfig{}, fmt.Errorf("prompt audit secret encryptor unavailable")
}
plain, err := encryptor.Decrypt(ep.TokenCiphertext)
if err != nil {
return ActiveConfig{}, fmt.Errorf("decrypt prompt audit endpoint token %q: %w", ep.ID, err)
// An undecryptable token (encryption key changed or regenerated)
// must not take the whole config down: admins would otherwise be
// locked out of the real config version and unable to recover
// (issue #4887). Keep the ciphertext persisted, but exclude the
// endpoint from runtime use until the token is re-entered.
tokenInvalid = true
} else {
token = plain
}
token = plain
}
active.Endpoints = append(active.Endpoints, ActiveEndpoint{
ID: ep.ID, Name: ep.Name, Protocol: ep.Protocol, BaseURL: ep.BaseURL, Model: ep.Model,
Token: token, TimeoutMS: ep.TimeoutMS, InputLimit: ep.InputLimit, Enabled: ep.Enabled,
Token: token, TimeoutMS: ep.TimeoutMS, InputLimit: ep.InputLimit,
Enabled: ep.Enabled && !tokenInvalid, TokenInvalid: tokenInvalid,
})
}
return active, nil
@@ -148,8 +148,8 @@ func TestPromptAuditConfigCASSecretRoundTripInvalidationAndTTL(t *testing.T) {
t.Cleanup(func() { require.NoError(t, redisClient.Close()) })
require.NoError(t, redisClient.Ping(context.Background()).Err())
managerOne := NewConfigManager(db, settingRepo, redisClient, encryptor)
managerTwo := NewConfigManager(db, settingRepo, redisClient, encryptor)
managerOne := NewConfigManager(db, settingRepo, redisClient, encryptor, testTotpKeyConfig())
managerTwo := NewConfigManager(db, settingRepo, redisClient, encryptor, testTotpKeyConfig())
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
require.NoError(t, managerOne.Start(ctx))
@@ -220,7 +220,7 @@ func TestPromptAuditConfigCASSecretRoundTripInvalidationAndTTL(t *testing.T) {
// A manager without Redis subscriptions must still converge through the
// bounded five-second refresh loop.
ttlManager := NewConfigManager(db, settingRepo, nil, encryptor)
ttlManager := NewConfigManager(db, settingRepo, nil, encryptor, testTotpKeyConfig())
require.NoError(t, ttlManager.Start(ctx))
t.Cleanup(func() { require.NoError(t, ttlManager.Shutdown(context.Background())) })
waitForConfigVersion(t, ttlManager, 3, time.Second)
@@ -233,7 +233,7 @@ func TestPromptAuditConfigCASSecretRoundTripInvalidationAndTTL(t *testing.T) {
// successfully committed PostgreSQL config.
deadRedis := redis.NewClient(&redis.Options{Addr: "127.0.0.1:1", MaxRetries: 0, DialTimeout: 30 * time.Millisecond, ReadTimeout: 30 * time.Millisecond, WriteTimeout: 30 * time.Millisecond})
t.Cleanup(func() { _ = deadRedis.Close() })
degraded := NewConfigManager(db, settingRepo, deadRedis, encryptor)
degraded := NewConfigManager(db, settingRepo, deadRedis, encryptor, testTotpKeyConfig())
require.NoError(t, degraded.Reload(context.Background()))
degradedSaved, err := degraded.Save(context.Background(), promptAuditUpdateRequest(4, 6, ""), 401)
require.NoError(t, err)
@@ -12,6 +12,7 @@ import (
"sync/atomic"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/redis/go-redis/v9"
@@ -29,6 +30,10 @@ type ConfigManager struct {
redis *redis.Client
encryptor SecretEncryptor
clock Clock
// encryptionKeyConfigured mirrors cfg.Totp.EncryptionKeyConfigured. With an
// auto-generated (per-boot) key, newly saved endpoint tokens would become
// undecryptable after the next restart, so Save rejects them (issue #4887).
encryptionKeyConfigured bool
snapshot atomic.Pointer[activeConfigSnapshot]
expected atomic.Int64
@@ -53,8 +58,11 @@ type ConfigManager struct {
wg sync.WaitGroup
}
func NewConfigManager(db *sql.DB, settings service.SettingRepository, redisClient *redis.Client, encryptor service.SecretEncryptor) *ConfigManager {
return &ConfigManager{db: db, settings: settings, redis: redisClient, encryptor: encryptor, clock: realClock{}}
func NewConfigManager(db *sql.DB, settings service.SettingRepository, redisClient *redis.Client, encryptor service.SecretEncryptor, cfg *config.Config) *ConfigManager {
return &ConfigManager{
db: db, settings: settings, redis: redisClient, encryptor: encryptor, clock: realClock{},
encryptionKeyConfigured: cfg != nil && cfg.Totp.EncryptionKeyConfigured,
}
}
func (m *ConfigManager) Start(ctx context.Context) error {
@@ -125,15 +133,45 @@ func (m *ConfigManager) Reload(ctx context.Context) error {
return err
}
now := m.clock.Now()
previous := m.snapshot.Load()
m.snapshot.Store(&activeConfigSnapshot{storage: cloneStorageConfig(storage), active: cloneActiveConfig(active), loadedAt: now})
m.configUntrusted.Store(false)
m.clearLoadError()
m.logInvalidTokenEndpoints(previous, active)
LogInfo(EventConfigLoaded, map[string]any{
"config_version": storage.ConfigVersion, "status": "loaded",
})
return nil
}
// 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) {
invalid := active.InvalidTokenEndpointIDs()
if len(invalid) == 0 {
return
}
if previous != nil {
prior := previous.active.InvalidTokenEndpointIDs()
if len(prior) == len(invalid) {
same := true
for i := range invalid {
if prior[i] != invalid[i] {
same = false
break
}
}
if same && previous.active.ConfigVersion == active.ConfigVersion {
return
}
}
}
LogWarn(EventConfigTokenInvalid, map[string]any{
"config_version": active.ConfigVersion, "status": "degraded",
"error_code": "endpoint_token_undecryptable", "guard_endpoint_id": strings.Join(invalid, ","),
})
}
func (m *ConfigManager) Active() (ActiveConfig, bool) {
if m == nil {
return ActiveConfig{}, false
@@ -202,7 +240,7 @@ func (m *ConfigManager) Public() (PublicConfig, error) {
if snapshot == nil {
return PublicConfig{}, infraerrors.ServiceUnavailable(ErrorCodeConfigUnavailable, "提示词审计配置暂不可用")
}
return PublicFromStorage(cloneStorageConfig(snapshot.storage), snapshot.active.RiskControlEnabled), nil
return PublicFromStorage(cloneStorageConfig(snapshot.storage), snapshot.active.RiskControlEnabled, snapshot.active.InvalidTokenEndpointIDs()), nil
}
func (m *ConfigManager) Save(ctx context.Context, req UpdateConfigRequest, actorID int64) (PublicConfig, error) {
@@ -268,11 +306,13 @@ func (m *ConfigManager) Save(ctx context.Context, req UpdateConfigRequest, actor
}
m.expected.Store(next.ConfigVersion)
m.expectedBlocking.Store(active.RiskControlEnabled && next.Enabled && next.BlockingEnabled)
previous := m.snapshot.Load()
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()
m.logInvalidTokenEndpoints(previous, active)
LogInfo(EventConfigUpdated, map[string]any{
"config_version": next.ConfigVersion, "status": "updated",
})
@@ -283,7 +323,7 @@ func (m *ConfigManager) Save(ctx context.Context, req UpdateConfigRequest, actor
})
}
}
return PublicFromStorage(next, active.RiskControlEnabled), nil
return PublicFromStorage(next, active.RiskControlEnabled, active.InvalidTokenEndpointIDs()), nil
}
func (m *ConfigManager) buildNextStorage(current storageConfig, req UpdateConfigRequest, actorID int64) (storageConfig, error) {
@@ -317,6 +357,10 @@ func (m *ConfigManager) buildNextStorage(current storageConfig, req UpdateConfig
case endpoint.ClearToken:
stored.TokenCiphertext = ""
case strings.TrimSpace(endpoint.Token) != "":
if !m.encryptionKeyConfigured {
return storageConfig{}, infraerrors.BadRequest(ErrorCodeEncryptionKeyRequired,
"未配置固定加密密钥,审计节点 Token 将在服务重启后失效。请先设置 TOTP_ENCRYPTION_KEY 环境变量(64 位十六进制)并重启服务")
}
ciphertext, err := m.encryptor.Encrypt(strings.TrimSpace(endpoint.Token))
if err != nil {
return storageConfig{}, fmt.Errorf("encrypt prompt audit endpoint token: %w", err)
@@ -4,8 +4,10 @@ import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/stretchr/testify/require"
)
@@ -13,7 +15,18 @@ import (
type prefixEncryptor struct{}
func (prefixEncryptor) Encrypt(value string) (string, error) { return "enc:" + value, nil }
func (prefixEncryptor) Decrypt(value string) (string, error) { return value[4:], nil }
func (prefixEncryptor) Decrypt(value string) (string, error) {
if !strings.HasPrefix(value, "enc:") {
return "", errors.New("cipher: message authentication failed")
}
return value[4:], nil
}
// testTotpKeyConfig mirrors a deployment with a fixed TOTP_ENCRYPTION_KEY so
// unit tests may persist endpoint tokens.
func testTotpKeyConfig() *config.Config {
return &config.Config{Totp: config.TotpConfig{EncryptionKeyConfigured: true}}
}
func TestDefaultConfigIsOff(t *testing.T) {
storage, err := ParseStorageConfig("")
@@ -23,7 +36,7 @@ func TestDefaultConfigIsOff(t *testing.T) {
require.NoError(t, err)
require.Equal(t, ModeOff, active.EffectiveMode())
require.Equal(t, AllScannerIDs, storage.Scanners)
publicJSON, err := json.Marshal(PublicFromStorage(storage, true))
publicJSON, err := json.Marshal(PublicFromStorage(storage, true, nil))
require.NoError(t, err)
require.Contains(t, string(publicJSON), `"group_ids":[]`)
require.Contains(t, string(publicJSON), `"endpoints":[]`)
@@ -38,7 +51,7 @@ func TestConfigRejectsBlockingWithoutAudit(t *testing.T) {
func TestPublicConfigNeverMarshalsToken(t *testing.T) {
storage := DefaultStorageConfig()
storage.Endpoints = []StorageEndpoint{{ID: "one", Name: "One", Protocol: "openai_compatible", BaseURL: "http://127.0.0.1:8080", Model: DefaultGuardModel, TokenCiphertext: "GUARD_TOKEN_CANARY_SECRET", TimeoutMS: 1000, InputLimit: 1000, Enabled: true}}
public := PublicFromStorage(storage, true)
public := PublicFromStorage(storage, true, nil)
raw, err := json.Marshal(public)
require.NoError(t, err)
require.NotContains(t, string(raw), "GUARD_TOKEN_CANARY_SECRET")
@@ -61,7 +74,7 @@ func TestConfigManagerPublicRequiresSuccessfullyLoadedSnapshot(t *testing.T) {
manager := NewConfigManager(nil, staticSettingRepository{values: map[string]string{
SettingKeyPromptAuditConfig: "",
SettingKeyRiskControl: "false",
}}, nil, prefixEncryptor{})
}}, nil, prefixEncryptor{}, testTotpKeyConfig())
require.NoError(t, manager.Reload(context.Background()))
public, err := manager.Public()
@@ -70,12 +83,14 @@ func TestConfigManagerPublicRequiresSuccessfullyLoadedSnapshot(t *testing.T) {
require.False(t, public.Enabled)
})
t.Run("persisted config activation failure is unavailable", func(t *testing.T) {
t.Run("unparseable persisted config is unavailable", func(t *testing.T) {
const canary = "persisted-token-canary"
manager := NewConfigManager(nil, staticSettingRepository{values: map[string]string{
// Endpoint without id/name fails validation, so no trustworthy
// snapshot can be installed from this raw value.
SettingKeyPromptAuditConfig: `{"enabled":true,"config_version":9,"endpoints":[{"token_ciphertext":"` + canary + `"}]}`,
SettingKeyRiskControl: "true",
}}, nil, prefixEncryptor{})
}}, nil, prefixEncryptor{}, testTotpKeyConfig())
require.Error(t, manager.Reload(context.Background()))
public, err := manager.Public()
@@ -95,7 +110,7 @@ func TestConfigManagerPublicRequiresSuccessfullyLoadedSnapshot(t *testing.T) {
SettingKeyPromptAuditConfig: string(raw),
SettingKeyRiskControl: "false",
}}}
manager := NewConfigManager(nil, repository, nil, prefixEncryptor{})
manager := NewConfigManager(nil, repository, nil, prefixEncryptor{}, testTotpKeyConfig())
require.NoError(t, manager.Reload(context.Background()))
repository.loadErr = errors.New("settings unavailable")
require.Error(t, manager.Reload(context.Background()))
@@ -107,8 +122,66 @@ func TestConfigManagerPublicRequiresSuccessfullyLoadedSnapshot(t *testing.T) {
})
}
// Regression coverage for issue #4887: a persisted config whose endpoint token
// can no longer be decrypted (encryption key changed or auto-generated per
// boot) must stay visible and editable for admins instead of falling back to a
// default v1 config that makes every save fail the CAS version check.
func TestConfigManagerUndecryptableTokenKeepsConfigVisibleAndRecoverable(t *testing.T) {
const canary = "persisted-token-canary"
persisted := `{"enabled":true,"blocking_enabled":false,"config_version":9,"endpoints":[{"id":"g1","name":"Guard","protocol":"openai_compatible","base_url":"http://127.0.0.1:8080","model":"m","token_ciphertext":"` + canary + `","timeout_ms":1000,"input_limit":1000,"enabled":true}]}`
manager := NewConfigManager(nil, staticSettingRepository{values: map[string]string{
SettingKeyPromptAuditConfig: persisted,
SettingKeyRiskControl: "true",
}}, nil, prefixEncryptor{}, testTotpKeyConfig())
require.NoError(t, manager.Reload(context.Background()), "an undecryptable token must not fail the whole config load")
public, err := manager.Public()
require.NoError(t, err)
require.Equal(t, int64(9), public.ConfigVersion, "admins must see the real persisted version so CAS saves can succeed")
require.Len(t, public.Endpoints, 1)
require.True(t, public.Endpoints[0].HasToken)
require.Equal(t, "invalid", public.Endpoints[0].TokenStatus)
raw, err := json.Marshal(public)
require.NoError(t, err)
require.NotContains(t, string(raw), canary)
active, ok := manager.Active()
require.True(t, ok)
require.Len(t, active.Endpoints, 1)
require.False(t, active.Endpoints[0].Enabled, "an endpoint with an undecryptable token must not be used at runtime")
require.True(t, active.Endpoints[0].TokenInvalid)
require.Empty(t, active.Endpoints[0].Token)
require.Empty(t, active.EnabledEndpoints())
require.Equal(t, []string{"g1"}, active.InvalidTokenEndpointIDs())
expected, activeVersion, _, _ := manager.RuntimeState()
require.Equal(t, int64(9), expected)
require.Equal(t, int64(9), activeVersion)
}
func TestConfigManagerUndecryptableTokenStillFailsClosedForBlockingIntent(t *testing.T) {
persisted := `{"enabled":true,"blocking_enabled":true,"config_version":9,"endpoints":[{"id":"g1","name":"Guard","protocol":"openai_compatible","base_url":"http://127.0.0.1:8080","model":"m","token_ciphertext":"undecryptable","timeout_ms":1000,"input_limit":1000,"enabled":true}]}`
manager := NewConfigManager(nil, staticSettingRepository{values: map[string]string{
SettingKeyPromptAuditConfig: persisted,
SettingKeyRiskControl: "true",
}}, nil, prefixEncryptor{}, testTotpKeyConfig())
require.NoError(t, manager.Reload(context.Background()))
require.Equal(t, ModeBlocking, manager.EffectiveMode())
service := &PromptService{config: manager, evaluator: NewGuardEvaluator(NewOpenAICompatibleScanner(), nil, nil)}
decision, err := service.Evaluate(context.Background(), Request{
Protocol: "openai_chat_completions",
Body: []byte(`{"messages":[{"role":"user","content":"hi"}]}`),
})
require.Error(t, err, "blocking intent with no usable endpoint must not let requests pass unaudited")
require.Nil(t, decision)
var guardErr *GuardError
require.ErrorAs(t, err, &guardErr)
require.Equal(t, ErrorCodeUnavailable, guardErr.Code)
}
func TestBuildNextStoragePreserveReplaceAndClearToken(t *testing.T) {
manager := &ConfigManager{encryptor: prefixEncryptor{}}
manager := &ConfigManager{encryptor: prefixEncryptor{}, encryptionKeyConfigured: true}
current := DefaultStorageConfig()
current.Endpoints = []StorageEndpoint{{ID: "one", Name: "One", Protocol: "openai_compatible", BaseURL: "http://127.0.0.1:8080", Model: DefaultGuardModel, TokenCiphertext: "enc:old", TimeoutMS: 1000, InputLimit: 1000}}
base := UpdateConfigRequest{ExpectedConfigVersion: 1, Strategy: "priority", WorkerCount: 1, QueueCapacity: 10, Scanners: []string{"PII"}, AllGroups: true,
@@ -130,6 +203,37 @@ func TestBuildNextStoragePreserveReplaceAndClearToken(t *testing.T) {
require.Empty(t, cleared.Endpoints[0].TokenCiphertext)
}
// Without a fixed encryption key the per-boot auto-generated key would make a
// freshly saved token undecryptable after the next restart (issue #4887), so
// saving a new token must be rejected with an actionable error. Preserving or
// clearing an existing ciphertext stays allowed so admins can still edit or
// disable the feature.
func TestBuildNextStorageRejectsNewTokenWithoutConfiguredEncryptionKey(t *testing.T) {
manager := &ConfigManager{encryptor: prefixEncryptor{}, encryptionKeyConfigured: false}
current := DefaultStorageConfig()
current.Endpoints = []StorageEndpoint{{ID: "one", Name: "One", Protocol: "openai_compatible", BaseURL: "http://127.0.0.1:8080", Model: DefaultGuardModel, TokenCiphertext: "enc:old", TimeoutMS: 1000, InputLimit: 1000}}
base := UpdateConfigRequest{ExpectedConfigVersion: 1, Strategy: "priority", WorkerCount: 1, QueueCapacity: 10, Scanners: []string{"PII"}, AllGroups: true,
Endpoints: []UpdateEndpoint{{ID: "one", Name: "One", Protocol: "openai_compatible", BaseURL: "http://127.0.0.1:8080", TimeoutMS: 1000, InputLimit: 1000}}}
newTokenReq := base
newTokenReq.Endpoints = append([]UpdateEndpoint(nil), base.Endpoints...)
newTokenReq.Endpoints[0].Token = "fresh-token"
_, err := manager.buildNextStorage(current, newTokenReq, 9)
require.Error(t, err)
require.Equal(t, ErrorCodeEncryptionKeyRequired, infraerrors.Reason(err))
preserved, err := manager.buildNextStorage(current, base, 9)
require.NoError(t, err)
require.Equal(t, "enc:old", preserved.Endpoints[0].TokenCiphertext)
clearedReq := base
clearedReq.Endpoints = append([]UpdateEndpoint(nil), base.Endpoints...)
clearedReq.Endpoints[0].ClearToken = true
cleared, err := manager.buildNextStorage(current, clearedReq, 9)
require.NoError(t, err)
require.Empty(t, cleared.Endpoints[0].TokenCiphertext)
}
func TestEffectiveModeTruthTable(t *testing.T) {
tests := []struct {
risk, enabled, blocking bool
@@ -203,7 +307,7 @@ func (r *switchableSettingRepository) GetMultiple(ctx context.Context, keys []st
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{})
manager := NewConfigManager(nil, errorSettingRepository{}, nil, prefixEncryptor{}, testTotpKeyConfig())
err := manager.Start(context.Background())
require.Error(t, err)
require.True(t, manager.configUntrusted.Load())
@@ -222,7 +326,7 @@ func TestConfigManagerStartupLoadFailureDoesNotBlockWhenBlockingNotIntended(t *t
}
func TestConfigManagerStartupLoadFailureFailsClosedWhenBlockingIntended(t *testing.T) {
manager := NewConfigManager(nil, errorSettingRepository{}, nil, prefixEncryptor{})
manager := NewConfigManager(nil, errorSettingRepository{}, nil, prefixEncryptor{}, testTotpKeyConfig())
// 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()
@@ -161,7 +161,7 @@ func TestPromptAdminConfigRequiresVersionMapsConflictAndNeverEchoesToken(t *test
func TestPromptAdminGetConfigReturnsSecretFreeUnavailableError(t *testing.T) {
const canary = "persisted-config-secret-canary"
repository := &switchableSettingRepository{loadErr: errors.New("failed to load token " + canary)}
manager := NewConfigManager(nil, repository, nil, prefixEncryptor{})
manager := NewConfigManager(nil, repository, nil, prefixEncryptor{}, testTotpKeyConfig())
require.Error(t, manager.Reload(context.Background()))
service := &PromptService{config: manager}
@@ -10,6 +10,7 @@ const (
EventConfigUpdated = "prompt_audit.config_updated"
EventConfigLoaded = "prompt_guard.config_loaded"
EventConfigReloadDegraded = "prompt_guard.config_reload_degraded"
EventConfigTokenInvalid = "prompt_guard.config_token_invalid"
EventProbeStarted = "prompt_audit.endpoint_probe_started"
EventProbeFinished = "prompt_audit.endpoint_probe_finished"
EventProbeFailed = "prompt_audit.endpoint_probe_failed"
@@ -37,7 +38,7 @@ const (
)
var knownLogEvents = map[string]struct{}{
EventConfigUpdated: {}, EventConfigLoaded: {}, EventConfigReloadDegraded: {},
EventConfigUpdated: {}, EventConfigLoaded: {}, EventConfigReloadDegraded: {}, EventConfigTokenInvalid: {},
EventProbeStarted: {}, EventProbeFinished: {}, EventProbeFailed: {},
EventJobEnqueued: {}, EventEnqueueSkipped: {}, EventEnqueueDropped: {},
EventAuditStarted: {}, EventProcessingReclaimed: {}, EventProcessed: {}, EventProcessFailed: {}, EventFindingRecorded: {},
@@ -33,7 +33,7 @@ func TestPromptAuditLogAllowlistAndErrorsDoNotLeakCanarySecrets(t *testing.T) {
beforeUnknown := output.Len()
LogWarn("prompt_audit.typo_event", map[string]any{"status": "failed"})
require.Equal(t, beforeUnknown, output.Len(), "events outside the stable dictionary must not be emitted")
require.Len(t, knownLogEvents, 27)
require.Len(t, knownLogEvents, 28)
_, err := NormalizeBaseURL("https://guard.example.test/path?token=" + canary)
require.Error(t, err)
@@ -39,7 +39,7 @@ func TestPromptServiceHasExplicitIdempotentLifecycle(t *testing.T) {
config := NewConfigManager(nil, staticSettingRepository{values: map[string]string{
SettingKeyPromptAuditConfig: "",
SettingKeyRiskControl: "false",
}}, nil, prefixEncryptor{})
}}, nil, prefixEncryptor{}, testTotpKeyConfig())
service := NewPromptService(
config,
NewPostgreSQLRepository(nil),
@@ -12,12 +12,13 @@ const (
ConfigInvalidationChannel = "sub2api:prompt_guard:config:invalidate"
PayloadKeyPrefix = "sub2api:prompt_audit:payload:"
ErrorCodeBlocked = "prompt_guard_blocked"
ErrorCodeUnavailable = "prompt_guard_unavailable"
ErrorCodeInvalidResponse = "prompt_guard_invalid_response"
ErrorCodeConfigConflict = "prompt_audit_config_conflict"
ErrorCodeConfigUnavailable = "prompt_audit_config_unavailable"
ErrorCodeRequiresEnabled = "prompt_guard_requires_audit_enabled"
ErrorCodeBlocked = "prompt_guard_blocked"
ErrorCodeUnavailable = "prompt_guard_unavailable"
ErrorCodeInvalidResponse = "prompt_guard_invalid_response"
ErrorCodeConfigConflict = "prompt_audit_config_conflict"
ErrorCodeConfigUnavailable = "prompt_audit_config_unavailable"
ErrorCodeEncryptionKeyRequired = "prompt_audit_encryption_key_required"
ErrorCodeRequiresEnabled = "prompt_guard_requires_audit_enabled"
DefaultGuardModel = "sileader/qwen3guard:0.6b"
)