mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 14:08:14 +08: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"
|
||||
)
|
||||
|
||||
@@ -50,6 +50,21 @@ describe('Prompt Audit components', () => {
|
||||
expect(wrapper.emitted('probe')?.[0]?.[0]).toMatchObject({ id: 'guard-1' })
|
||||
})
|
||||
|
||||
it('surfaces an undecryptable saved credential and prompts for re-entry', async () => {
|
||||
const invalidEndpoint = { ...endpoint(), token_status: 'invalid' }
|
||||
const wrapper = mount(EndpointPool, {
|
||||
props: { endpoints: [invalidEndpoint], probeResults: {}, probingIds: [] },
|
||||
global: { stubs: { BaseDialog: DialogStub } },
|
||||
})
|
||||
expect(wrapper.text()).toContain('admin.promptAudit.pool.invalid')
|
||||
expect(wrapper.text()).not.toContain('admin.promptAudit.pool.configured')
|
||||
|
||||
const edit = wrapper.findAll('button').find((button) => button.text().includes('common.edit'))
|
||||
await edit!.trigger('click')
|
||||
const token = wrapper.get<HTMLInputElement>('[aria-label="admin.promptAudit.pool.apiKey"]')
|
||||
expect(token.attributes('placeholder')).toContain('admin.promptAudit.pool.reenterSecret')
|
||||
})
|
||||
|
||||
it('supports group search, stale configured groups, nine scanners, and bounded worker inputs', async () => {
|
||||
const draft: PromptAuditDraft = {
|
||||
enabled: true, blocking_enabled: false, store_pass_events: false, effective_mode: 'async_audit', strategy: 'priority',
|
||||
|
||||
@@ -68,9 +68,9 @@
|
||||
|
||||
<div class="min-w-0">
|
||||
<p class="mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 xl:hidden">{{ t('admin.promptAudit.pool.credential') }}</p>
|
||||
<div class="flex items-center gap-1.5 text-xs font-medium" :class="hasCredential(endpoint) ? 'text-emerald-700 dark:text-emerald-300' : 'text-gray-500 dark:text-dark-400'">
|
||||
<span class="h-1.5 w-1.5 rounded-full" :class="hasCredential(endpoint) ? 'bg-emerald-500' : 'bg-gray-300 dark:bg-dark-500'" aria-hidden="true" />
|
||||
{{ hasCredential(endpoint) ? t('admin.promptAudit.pool.configured') : t('admin.promptAudit.pool.missing') }}
|
||||
<div class="flex items-center gap-1.5 text-xs font-medium" :class="credentialInvalid(endpoint) ? 'text-red-600 dark:text-red-300' : hasCredential(endpoint) ? 'text-emerald-700 dark:text-emerald-300' : 'text-gray-500 dark:text-dark-400'">
|
||||
<span class="h-1.5 w-1.5 rounded-full" :class="credentialInvalid(endpoint) ? 'bg-red-500' : hasCredential(endpoint) ? 'bg-emerald-500' : 'bg-gray-300 dark:bg-dark-500'" aria-hidden="true" />
|
||||
{{ credentialInvalid(endpoint) ? t('admin.promptAudit.pool.invalid') : hasCredential(endpoint) ? t('admin.promptAudit.pool.configured') : t('admin.promptAudit.pool.missing') }}
|
||||
</div>
|
||||
<p v-if="probingIds.includes(endpoint.id)" class="mt-1.5 text-xs text-primary-600 dark:text-primary-300">
|
||||
{{ t('admin.promptAudit.pool.probeProgress') }}
|
||||
@@ -108,7 +108,7 @@
|
||||
</label>
|
||||
<label class="space-y-1 text-sm text-gray-700 dark:text-dark-200 sm:col-span-2">
|
||||
<span>{{ t('admin.promptAudit.pool.apiKey') }}</span>
|
||||
<input v-model="editing.token" class="input w-full" type="password" autocomplete="new-password" :placeholder="editing.has_token ? t('admin.promptAudit.pool.keepSecret') : ''" :aria-label="t('admin.promptAudit.pool.apiKey')" />
|
||||
<input v-model="editing.token" class="input w-full" type="password" autocomplete="new-password" :placeholder="editing.has_token ? (editing.token_status === 'invalid' ? t('admin.promptAudit.pool.reenterSecret') : t('admin.promptAudit.pool.keepSecret')) : ''" :aria-label="t('admin.promptAudit.pool.apiKey')" />
|
||||
<span class="block text-xs text-gray-500 dark:text-dark-400">{{ t('admin.promptAudit.pool.secretHint') }}</span>
|
||||
</label>
|
||||
<label v-if="editing.has_token" class="flex items-center gap-2 text-sm text-red-600 dark:text-red-300 sm:col-span-2">
|
||||
@@ -190,4 +190,7 @@ function removeEndpoint(endpoint: PromptAuditEndpointDraft) {
|
||||
function hasCredential(endpoint: PromptAuditEndpointDraft): boolean {
|
||||
return Boolean(endpoint.token.trim() || (endpoint.has_token && !endpoint.clear_token))
|
||||
}
|
||||
function credentialInvalid(endpoint: PromptAuditEndpointDraft): boolean {
|
||||
return endpoint.token_status === 'invalid' && !endpoint.token.trim() && !endpoint.clear_token
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface PromptAuditEndpoint {
|
||||
input_limit: number
|
||||
enabled: boolean
|
||||
has_token: boolean
|
||||
token_status: 'configured' | 'missing' | string
|
||||
token_status: 'configured' | 'missing' | 'invalid' | string
|
||||
}
|
||||
|
||||
export interface PromptAuditEndpointDraft extends PromptAuditEndpoint {
|
||||
|
||||
@@ -44,9 +44,9 @@ export default {
|
||||
pool: {
|
||||
title: 'Audit pool', description: 'Enabled OpenAI-compatible nodes are tried in order. Probes run from the server network.',
|
||||
add: 'Add node', edit: 'Edit node', empty: 'No audit nodes configured.', node: 'Node', model: 'Model', limits: 'Timeout / chunk limit', credential: 'Credential and probe',
|
||||
configured: 'API Key configured', missing: 'API Key missing', probe: 'Test connection', probing: 'Probing…',
|
||||
configured: 'API Key configured', missing: 'API Key missing', invalid: 'API Key cannot be decrypted; re-enter it', probe: 'Test connection', probing: 'Probing…',
|
||||
probeProgress: 'Config validated ✓ · request sent · awaiting service response…', probeResult: 'Config ✓ · request ✓ · HTTP {http} · {status} · {latency} ms',
|
||||
name: 'Node name', id: 'Stable node ID', baseUrl: 'Base URL', apiKey: 'API Key', keepSecret: 'Leave blank to keep the saved API Key',
|
||||
name: 'Node name', id: 'Stable node ID', baseUrl: 'Base URL', apiKey: 'API Key', keepSecret: 'Leave blank to keep the saved API Key', reenterSecret: 'The saved API Key cannot be decrypted (encryption key changed); enter a new one',
|
||||
secretHint: 'Plaintext exists only in this editor and is cleared immediately after a successful save.', clearSecret: 'Explicitly clear the saved API Key', timeout: 'Total timeout (ms)', inputLimit: 'Unicode characters per chunk',
|
||||
toggleNode: 'Toggle node {name}', deleteConfirm: 'Remove “{name}” from the draft? It takes effect after saving.',
|
||||
},
|
||||
@@ -93,6 +93,7 @@ export default {
|
||||
errors: {
|
||||
loadConfig: 'Unable to load Prompt Audit configuration.', loadRuntime: 'Unable to load Prompt Audit runtime.', loadGroups: 'Unable to load groups.', loadEvents: 'Unable to load audit events.', loadDetail: 'Unable to load event details.', saveConfig: 'Unable to save the configuration.', probe: 'Node probe failed.', delete: 'Unable to delete events.', previewDelete: 'Unable to create a deletion preview. Check the time range.', deleteConfirmation: 'The deletion confirmation is invalid or expired. Preview again.',
|
||||
prompt_audit_config_conflict: 'Another administrator updated this configuration. Reload the server version before deciding how to merge your draft.',
|
||||
prompt_audit_encryption_key_required: 'No fixed encryption key is configured, so audit node API Keys would be lost on restart. Set the TOTP_ENCRYPTION_KEY environment variable and restart the service first.',
|
||||
prompt_guard_requires_audit_enabled: 'Enable Prompt Audit before synchronous blocking.', prompt_audit_invalid_endpoint: 'The audit node configuration is invalid.', prompt_audit_endpoint_required: 'Enable at least one audit node before enabling Prompt Audit.', prompt_audit_groups_required: 'Select at least one group in selected-group mode.', prompt_audit_scanners_required: 'Enable at least one risk category.',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -44,9 +44,9 @@ export default {
|
||||
pool: {
|
||||
title: '审计池', description: '按顺序使用启用的 OpenAI 兼容节点;探测由服务端真实网络环境发起。',
|
||||
add: '新增节点', edit: '编辑节点', empty: '尚未配置审计节点。', node: '节点', model: '模型', limits: '超时 / 单片上限', credential: '凭据与探测',
|
||||
configured: 'API Key 已配置', missing: '未配置 API Key', probe: '连接测试', probing: '探测中…',
|
||||
configured: 'API Key 已配置', missing: '未配置 API Key', invalid: 'API Key 无法解密,请重新输入', probe: '连接测试', probing: '探测中…',
|
||||
probeProgress: '配置校验 ✓ · 请求已发送 · 等待服务响应…', probeResult: '配置校验 ✓ · 请求 ✓ · HTTP {http} · {status} · {latency} ms',
|
||||
name: '节点名称', id: '稳定节点 ID', baseUrl: 'Base URL', apiKey: 'API Key', keepSecret: '留空以保留已保存的 API Key',
|
||||
name: '节点名称', id: '稳定节点 ID', baseUrl: 'Base URL', apiKey: 'API Key', keepSecret: '留空以保留已保存的 API Key', reenterSecret: '已保存的 API Key 无法解密(加密密钥已变更),请重新输入',
|
||||
secretHint: '明文只在本次编辑内存中存在;保存成功后会立即清除。', clearSecret: '显式清除已保存的 API Key', timeout: '总超时(毫秒)', inputLimit: '单片 Unicode 字符上限',
|
||||
toggleNode: '切换节点 {name}', deleteConfirm: '从草稿中删除节点“{name}”?保存配置后生效。',
|
||||
},
|
||||
@@ -93,6 +93,7 @@ export default {
|
||||
errors: {
|
||||
loadConfig: '无法加载提示词审计配置。', loadRuntime: '无法加载提示词审计运行态。', loadGroups: '无法加载分组列表。', loadEvents: '无法加载审计事件。', loadDetail: '无法加载事件详情。', saveConfig: '配置保存失败。', probe: '节点探测失败。', delete: '事件删除失败。', previewDelete: '无法生成删除预览,请检查时间范围。', deleteConfirmation: '删除确认无效或已过期,请重新预览。',
|
||||
prompt_audit_config_conflict: '配置已被其他管理员更新。请重新加载服务端配置,再决定如何合并本地草稿。',
|
||||
prompt_audit_encryption_key_required: '未配置固定加密密钥,审计节点 API Key 将在服务重启后失效。请先设置 TOTP_ENCRYPTION_KEY 环境变量并重启服务。',
|
||||
prompt_guard_requires_audit_enabled: '开启同步阻止前必须先启用提示词审计。', prompt_audit_invalid_endpoint: '审计节点配置无效。', prompt_audit_endpoint_required: '启用审计前至少需要一个启用节点。', prompt_audit_groups_required: '指定分组模式至少需要选择一个分组。', prompt_audit_scanners_required: '至少需要启用一个风险分类。',
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user