mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-27 08:05:33 +00:00
Merge pull request #4983 from Wei-Shaw/fix/issue-4887-prompt-audit-recovery
fix(security-audit): 解密失败不再吞掉整份配置,修复升级后配置消失且无法保存的死锁
This commit is contained in:
@@ -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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user