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

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

修复:
- ActiveFromStorage 对单节点解密失败降级容忍:该节点运行时禁用并标记
  TokenInvalid,配置整体照常激活;管理端恢复显示真实版本号,重新输入
  Token 即可自愈(密文保留,密钥恢复后自动复原)
- blocking 意图下零可用节点时 evaluator 仍返回 unavailable,请求照旧
  被拒,fail-closed 语义不回归;async 意图下 enqueue 直接 drop 并告警
- Save 在未配置固定加密密钥时拒绝保存新 Token(与 TOTP/Ollama/备份
  一致的门控),错误码 prompt_audit_encryption_key_required
- token_status 新增 invalid 状态,前端凭据列与编辑框提示重新输入
- 新增 config_token_invalid 告警日志(集合变化时记录一次,不随 5s
  刷新刷屏)
This commit is contained in:
shaw committed 2026-07-28 09:31:36 +08:00
1 parent 59ce11c780
commit bfbe113f5e
15 files changed
+244 -42

No files matched your search

@@ -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>
+1 -1
View File
@@ -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 {