diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 43c108db4d..8ab1443a2a 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -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() diff --git a/backend/internal/securityaudit/prompt_config.go b/backend/internal/securityaudit/prompt_config.go index e7238b517f..c7c9f24ac5 100644 --- a/backend/internal/securityaudit/prompt_config.go +++ b/backend/internal/securityaudit/prompt_config.go @@ -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 diff --git a/backend/internal/securityaudit/prompt_config_integration_test.go b/backend/internal/securityaudit/prompt_config_integration_test.go index 3817abe05d..6dbcba4611 100644 --- a/backend/internal/securityaudit/prompt_config_integration_test.go +++ b/backend/internal/securityaudit/prompt_config_integration_test.go @@ -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) diff --git a/backend/internal/securityaudit/prompt_config_store.go b/backend/internal/securityaudit/prompt_config_store.go index 70d17da943..b832323e8c 100644 --- a/backend/internal/securityaudit/prompt_config_store.go +++ b/backend/internal/securityaudit/prompt_config_store.go @@ -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) diff --git a/backend/internal/securityaudit/prompt_config_test.go b/backend/internal/securityaudit/prompt_config_test.go index b71fca1d57..96cc5a5533 100644 --- a/backend/internal/securityaudit/prompt_config_test.go +++ b/backend/internal/securityaudit/prompt_config_test.go @@ -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() diff --git a/backend/internal/securityaudit/prompt_handler_test.go b/backend/internal/securityaudit/prompt_handler_test.go index 8561ad93e9..0ec2ff5d8b 100644 --- a/backend/internal/securityaudit/prompt_handler_test.go +++ b/backend/internal/securityaudit/prompt_handler_test.go @@ -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} diff --git a/backend/internal/securityaudit/prompt_logging.go b/backend/internal/securityaudit/prompt_logging.go index 4085c8512d..1262dd6cf8 100644 --- a/backend/internal/securityaudit/prompt_logging.go +++ b/backend/internal/securityaudit/prompt_logging.go @@ -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: {}, diff --git a/backend/internal/securityaudit/prompt_logging_test.go b/backend/internal/securityaudit/prompt_logging_test.go index ff936a1ce6..208e8fbf69 100644 --- a/backend/internal/securityaudit/prompt_logging_test.go +++ b/backend/internal/securityaudit/prompt_logging_test.go @@ -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) diff --git a/backend/internal/securityaudit/prompt_service_test.go b/backend/internal/securityaudit/prompt_service_test.go index 93d82df562..5034b31af6 100644 --- a/backend/internal/securityaudit/prompt_service_test.go +++ b/backend/internal/securityaudit/prompt_service_test.go @@ -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), diff --git a/backend/internal/securityaudit/prompt_types.go b/backend/internal/securityaudit/prompt_types.go index 7b25c33bdd..656ec6a7bd 100644 --- a/backend/internal/securityaudit/prompt_types.go +++ b/backend/internal/securityaudit/prompt_types.go @@ -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" ) diff --git a/frontend/src/features/prompt-audit/__tests__/components.spec.ts b/frontend/src/features/prompt-audit/__tests__/components.spec.ts index ee35669331..e9d679527a 100644 --- a/frontend/src/features/prompt-audit/__tests__/components.spec.ts +++ b/frontend/src/features/prompt-audit/__tests__/components.spec.ts @@ -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('[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', diff --git a/frontend/src/features/prompt-audit/components/EndpointPool.vue b/frontend/src/features/prompt-audit/components/EndpointPool.vue index bd9f46800b..c90293fdd7 100644 --- a/frontend/src/features/prompt-audit/components/EndpointPool.vue +++ b/frontend/src/features/prompt-audit/components/EndpointPool.vue @@ -68,9 +68,9 @@

{{ t('admin.promptAudit.pool.credential') }}

-
-