fix(store-accounts): 按明文存储规则修正密码状态 #21

Merged
WuYongJie merged 11 commits from codex/xuxiake-mainline into main 2026-09-09 11:05:13 +08:00
3 changed files with 171 additions and 30 deletions

View File

@ -1,14 +1,12 @@
# 财务系统店铺密码直接显示契约
状态:代码和本地测试交付;生产迁移、配置、发布及真实接口验收须单独批准。
> 2026-09-08 存储变更:按管理层决定,店铺账号密码/银行卡号取消数据库加密,改为明文落库;存量 `v1:` 密文已由 `scripts/migrate-store-account-plaintext-20260908.js` 迁移。本契约的读取链路兼容明文与历史密文,认证、审计、限流要求不变;下文"存储事实与授权边界"中的加密描述保留为历史设计记录。
状态:生产接口已上线;2026-09-09 按当前明文存储规则修正 `passwordConfigured`。
## 存储事实与授权边界
- 密码真相源是 `public."StoreAccount"."passwordCiphertext"`,使用现有 `src/lib/store-account-crypto.ts` 的 AES-256-GCM(随机 12 字节 IV、认证标签、`v1:` 封装)。不是明文或不可逆 Hash。
- 生产密钥来自 `STORE_ACCOUNT_ENCRYPTION_KEY`,不从数据库读取,不复用财务服务认证凭据。已有银行账号加密也使用此密钥,轮换时必须同时处理。
- 2026-09-08 对 `furniture3d_prod` 的只读核验:`accounts_v1` COUNT=958、DISTINCT id=958;957 条非空密文均可在生产主机内存中解密,空值 1、解密失败 0。过程只输出聚合,不导出密码或密文。
- 密码真相源是 `public."StoreAccount"."passwordCiphertext"`。字段名是历史遗留;当前规则将新值以明文保存,读取链路对历史 `v1:` AES-256-GCM 值保留兼容。
- 存量 `v1:` 值已由 `scripts/migrate-store-account-plaintext-20260908.js` 迁移。`STORE_ACCOUNT_ENCRYPTION_KEY` 仅用于可能残留或恢复出的历史 `v1:` 值,不参与当前明文值读取。
- `passwordConfigured` 只表示存储值是否非空,不校验字符集或密码强度;因此包含 `+`、`/`、`=` 等字符的密码也会正确返回 true。它不返回密码本身。
- 普通 `store_account_reader` 可读视图、不能读取基表或密码列。此次迁移不增加其基表权限。
- 本接口只信任专用服务身份 `finance-store-accounts`。财务系统必须在自身后端校验用户登录态、查看/复制权限及店铺范围,然后从可信会话中取 `requesterId`。不得把客户端传入的任意人员 ID 当成操作者。服务凭据仅保存在财务后端,禁止交给浏览器。
- 不提供密码列表或批量导出。财务页面可在已通过自身页面权限校验后,自动读取当前可见页最多 50 个账号;CSV 和共享镜像仍只读取 `passwordConfigured`。复制操作继续走单账号接口。上游审计记录的用户 ID 是专用财务服务的身份断言,未授权请求中的 ID 不可信,`serviceId=null`。
@ -91,7 +89,7 @@ Content-Type: application/json
| 名称 | 所在系统与要求 |
|---|---|
| STORE_ACCOUNT_ENCRYPTION_KEY | 上游已有独立加密密钥,至少 32 字符;生产不回退到 JWT_SECRET |
| STORE_ACCOUNT_ENCRYPTION_KEY | 仅用于兼容历史 `v1:` 密文,至少 32 字符;生产不回退到 JWT_SECRET |
| STORE_ACCOUNT_FINANCE_TOKEN | 上游专用服务令牌,密码学随机至少 32 字节,base64url 编码,43–128 字符;缺失时拒绝全部调用 |
| STORE_ACCOUNT_FINANCE_TOKEN_PREVIOUS | 上游可选旧令牌,仅用于短时轮换;主令牌缺失时旧令牌也不生效 |
| STORE_ACCOUNT_REVEAL_EDGE_TOKEN | 上游与可信 TLS 网关之间的独立随机凭据,base64url 43–128 字符;与财务令牌完全分离,不提供给财务系统;缺失时拒绝全部调用 |
@ -108,25 +106,25 @@ Content-Type: application/json
2. 应用不以 URL 或未经认证的转发头证明 TLS。必须由受控网关在实际 HTTPS 入口覆盖 `X-Forwarded-Proto: https` 及 `X-Store-Reveal-Edge-Token`(值来自独立 `STORE_ACCOUNT_REVEAL_EDGE_TOKEN`),剥除客户端原有同名头;HTTP 入口直接拒绝,不能注入上述凭据。应用对网关凭据做定时安全比较,缺失/错误或协议不是 https 时拒绝。凭据仅上游和网关持有,不提供给财务系统;配置文件 0600,不通过 `nginx -T` 等诊断输出展示。仍需封闭应用直连并逐跳验证 TLS,独立凭据不代替网络加密。
3. 对精确路径 `/internal/store-accounts/*/password/reveal` 禁止网关缓存、响应落盘缓冲、CDN缓存、请求/响应体及 Authorization 采集;包括 APM、WAF、异常上报、链路追踪和调试抓包。网关日志只允许时间、方法、路径(不带参数)、状态、耗时、追踪 ID。不要采集响应对象。
4. 网关自己的 403/404/429/503 也使用脱敏 JSON、`Cache-Control: no-store` 和追踪 ID;不得返回上游诊断页。禁止将该路由接入通用 `withApi`/request logger;目前路由直接返回白名单响应,不记录异常对象。
5. 数据库不执行明文解密,SQL 参数不包含明文密码。现有密码写入或密钥轮换时,关闭 PostgreSQL/pgAudit 的绑定参数及错误参数记录,防止记录密文。不要启用该连接池的 SQL 结果或参数追踪。
5. 数据库查询结果包含当前明文密码;严禁记录 SQL 结果、绑定参数或异常对象,不要启用该连接池的参数/结果追踪。密码写入及历史格式迁移期间同样关闭 PostgreSQL/pgAudit 的绑定参数和错误参数记录。
6. 财务后端同样禁用正文采集、缓存及异常对象打印。浏览器在进入已授权页面后只读取当前可见页,避免 localStorage/sessionStorage/Service Worker 持久化;页面隐藏或离开时清除页面密码,不把密码放入通知、埋点或消息队列。
## 视图与审计迁移
迁移:`prisma/migrations/20260908120000_store_account_password_reveal/migration.sql`。
初始迁移:`prisma/migrations/20260908120000_store_account_password_reveal/migration.sql`。明文状态修复:`prisma/migrations/20260909100000_fix_store_account_password_configured_plaintext/migration.sql`。
- 新增 `StoreAccountPasswordAccessAudit`,固定字段为请求追踪 ID、人员标识、服务身份、账号 ID、用途、HTTP状态、数据库时间;不关联用户/店铺外键,以保留未授权、不存在和删除账号的失败审计。状态 200 表示成功,其他状态表示失败。
- 只在原视图存在时,以原视图查询作为子查询,在末尾追加 `passwordConfigured boolean`,保留原字段、筛选、每行内容、ID、所有者与 ACL;`EXISTS` 不会产生重复行。未配置外部视图的环境不擅自创建外部读入口。
- `passwordConfigured` 表示存在格式完整的、非空 AES-GCM 密码封装。SQL 不接触密钥,所以不在每次列表查询时验证 GCM 标签/密钥健康;生产预检必须做主机内存中的解密聚合验证。密钥丢失或密文损坏属于 503 运维故障,不能把它伪装成未设置密码。
- 无历史明文迁移,无现有密码或银行账号数据变更,无额外只读权限。审计数据增量写入,上线后按安全审计要求保留至少 365 天;此 PR 不创建自动删除任务。
- `passwordConfigured` 的当前定义为 `passwordCiphertext <> ''`。修复迁移仅替换这个布尔表达式,并在事务内核对原字段逐行内容、视图所有者和 ACL 未变;不读取或输出密码。
- 明文迁移已由独立脚本完成。本状态修复不改任何密码或银行账号数据,也不增加普通只读账号权限。审计数据至少保留 365 天。
迁移前由 DBA 只读确认目标库、视图定义/所有者/ACL、字段顺序、行数与唯一性、密码聚合、普通读取账号和其成员角色权限。若生产仍以本次基线验收,COUNT 与 DISTINCT id 必须均为 958;若业务新增/删除则暂停并重新确认基线,不改数据凑数。
备份与执行顺序(生产必须在明确批准后执行):
1. 备份当前 `main`/部署 SHA、现有视图 DDL/ACL/所有者及 `_prisma_migrations` 状态。生成目标库 `pg_dump -Fc` 备份并验证可读取;备份进入生产主机权限 0700 目录,文件 0600,不进入 Git 或共享日志。备份可能含密文,按敏感材料管理。
2. 本次不换加密密钥;核实独立密钥在受限凭据存储中有可恢复副本,不在命令行、文档中显示。记录备份校验和、时间及恢复演练证据。
3. 保持新财务令牌未配置,执行批准的该迁移,视图短时获得 DDL 锁(最多等 5 秒,执行预算 30 秒),新增审计表及索引;不回放未审查的其他迁移。
2. 本次不修改任何密码、银行卡号或服务令牌;备份不得进入 Git 或普通日志。
3. 执行明文状态修复迁移,视图短时获得 DDL 锁(最多等 5 秒,执行预算 30 秒);不回放未审查的其他迁移。
4. 在同一只读快照比较迁移前后原字段行集、顺序/类型、行数/唯一性和权限;运行 `scripts/sql/check-store-account-password-reveal.sql`,确认最后字段 boolean,报告 true/false/null 聚合。预计当前数据为 957/1/0,但必须执行后实测。
5. 按项目主线发布流程部署,完成逐跳 TLS、网关日志/限流/缓存检查后再配令牌。生产虚构账号的创建、调用与删除属于额外生产写入,需单独批准;不要调用真实账号作为测试。
@ -138,7 +136,7 @@ Content-Type: application/json
网关凭据:暂停此接口,在网关及上游同时更新 `STORE_ACCOUNT_REVEAL_EDGE_TOKEN` 后验收并恢复;不支持旧值并行。不得将网关凭据与服务令牌设成相同值。
数据加密密钥:现有 `v1:` 不含 key ID,不能直接替换环境变量。必须另行批准维护窗口,暂停所有店铺密码及银行账号的写入/读取,备份对应表和旧密钥;用受控迁移进程在内存中旧钥解密、新钥重新 AES-GCM 加密,并在同一数据库事务中更新两类密文字段(SQL 永不接收明文)。验证全量解密聚合、行数及虚构账号后,协调所有应用实例切换新环境密钥,再恢复流量;失败时同时恢复密文备份和旧钥。关闭 SQL 参数/异常参数、APM及备份内容输出;本 PR 不执行此轮换,也不引入第二套密钥或把新旧密钥存入数据库。需要无停机轮换时,应另行实现带 key ID 的 Envelope Encryption/KMS 版本迁移。
历史兼容密钥:当前明文规则不需要为新值轮换数据密钥。若确认数据库中不存在 `v1:` 值并且备份保留策略允许,可另行评估退役历史解密密钥;退役前必须检查备份恢复场景,不能直接删除。
## 本次验证记录
@ -148,8 +146,8 @@ Content-Type: application/json
- 隔离本地 PostgreSQL 集成测试:10 项通过,覆盖完整加解密与 HTTP handler、已提交审计、403/404/429/no-store、并发/跨实例限流、分钟/24小时配额、审计失败拒绝泄露、原字段及筛选保留、958/958 与 boolean 957/1/0、普通只读角色拒绝基表/密码/审计读取。
- 开启本地测试连接的 PostgreSQL statement 日志,只在内存扫描 `docker logs`;应用日志、审计行、SQL请求与真实数据库日志均未检出随机测试密码。测试无需打印测试密码,断言只输出布尔结果。
- Type-check 通过;范围内 ESLint 通过。全仓库主数据边界巡检有 6 项已有违规,均位于本次未修改模块;不得据此声称全仓库通过。
- 生产仅完成只读基线核查;生产 `passwordConfigured` 尚未落库,生产接口尚未发布,因此没有生产新增字段/新接口的验收结论。
- 2026-09-09 生产只读复核确认:958 行、958 个唯一 ID、957 条非空密码、1 条空密码;旧正则导致状态误报为全 false。本迁移用于恢复预期聚合 true/false/null = 957/1/0。
运行:`npx jest --runInBand --runTestsByPath src/server/services/store-account-password-reveal.service.test.ts src/proxy-store-account-reveal.test.ts "src/app/internal/store-accounts/[id]/password/reveal/route.test.ts" tests/integration/store-account-password-reveal.test.ts`。未配置本地测试库变量时,集成套件明确 skip,不能算真实数据库验收通过。
收口:**passed with warnings**。这是自审,不是独立复审。Route → Service → Repository 边界保持;未修改 UI;复用密码真相源、加密和专用服务认证,未通过明文视图绕开根因。剩余门禁为生产批准、备份/TLS拓扑/网关配置及发布后真实验收。
收口:**passed with warnings**。这是自审,不是独立复审。Route → Service → Repository 边界保持;未修改 UI;状态字段与当前明文存储规则对齐,专用认证、审计和限流边界不变。

View File

@ -0,0 +1,92 @@
BEGIN;
SET LOCAL lock_timeout = '5s';
SET LOCAL statement_timeout = '30s';
DO $preflight$
DECLARE
actual_columns TEXT[];
expected_columns CONSTANT TEXT[] := ARRAY[
'id', 'storeName', 'operationOwner', 'operationDepartmentHead',
'operationDirector', 'profitCenter', 'subject', 'accountType', 'account',
'boundPhone', 'verificationContact', 'jstOrderService', 'accountGroupName',
'createdAt', 'updatedAt', 'passwordConfigured'
];
BEGIN
IF to_regclass('external_store_accounts.accounts_v1') IS NULL THEN
RETURN;
END IF;
SELECT array_agg(column_name ORDER BY ordinal_position)
INTO actual_columns
FROM information_schema.columns
WHERE table_schema = 'external_store_accounts'
AND table_name = 'accounts_v1';
IF actual_columns IS DISTINCT FROM expected_columns THEN
RAISE EXCEPTION 'External account contract needs preflight review';
END IF;
END $preflight$;
DO $migration$
DECLARE
original_owner OID;
original_acl ACLITEM[];
BEGIN
IF to_regclass('external_store_accounts.accounts_v1') IS NULL THEN
RETURN;
END IF;
SELECT relowner, relacl
INTO original_owner, original_acl
FROM pg_class
WHERE oid = 'external_store_accounts.accounts_v1'::regclass;
CREATE TEMP TABLE store_account_view_rows_before ON COMMIT DROP AS
SELECT to_jsonb(account_row) - 'passwordConfigured' AS row_value
FROM external_store_accounts.accounts_v1 account_row;
CREATE OR REPLACE VIEW external_store_accounts.accounts_v1 AS
SELECT
source.id,
source."storeName",
source."operationOwner",
source."operationDepartmentHead",
source."operationDirector",
source."profitCenter",
source.subject,
source."accountType",
source.account,
source."boundPhone",
source."verificationContact",
source."jstOrderService",
source."accountGroupName",
source."createdAt",
source."updatedAt",
(source."passwordCiphertext" <> '')::boolean AS "passwordConfigured"
FROM public."StoreAccount" source;
IF EXISTS (
(SELECT row_value FROM store_account_view_rows_before
EXCEPT ALL
SELECT to_jsonb(account_row) - 'passwordConfigured'
FROM external_store_accounts.accounts_v1 account_row)
UNION ALL
(SELECT to_jsonb(account_row) - 'passwordConfigured'
FROM external_store_accounts.accounts_v1 account_row
EXCEPT ALL
SELECT row_value FROM store_account_view_rows_before)
) THEN
RAISE EXCEPTION 'External account rows changed while fixing password status';
END IF;
IF EXISTS (
SELECT 1
FROM pg_class
WHERE oid = 'external_store_accounts.accounts_v1'::regclass
AND (relowner <> original_owner OR relacl IS DISTINCT FROM original_acl)
) THEN
RAISE EXCEPTION 'External account owner or ACL changed';
END IF;
END $migration$;
COMMIT;

View File

@ -20,11 +20,11 @@ jest.mock('@/server/services/store-account-password-reveal.service', () => ({
// Explicit opt-in, loopback only. Creates and removes only its own random fixture DB/role.
const adminUrl = process.env.STORE_REVEAL_TEST_ADMIN_URL
const integration = adminUrl ? describe : describe.skip
integration('finance reveal with real PostgreSQL, migration and crypto', () => {
integration('finance reveal with real PostgreSQL and plaintext storage', () => {
const suffix = randomBytes(8).toString('hex')
const dbName = `store_reveal_test_${suffix}`
const readerName = `store_reveal_reader_${suffix}`
const password = randomBytes(24).toString('base64url')
const password = `synthetic-${randomBytes(18).toString('base64')}=+`
const token = randomBytes(32).toString('base64url')
const edgeToken = randomBytes(32).toString('base64url')
const startedAt = new Date().toISOString()
@ -52,17 +52,34 @@ integration('finance reveal with real PostgreSQL, migration and crypto', () => {
beforeAll(async () => {
const target = new URL(adminUrl!)
if (!['localhost', '127.0.0.1', '[::1]'].includes(target.hostname)) throw new Error('Local test database required')
admin = new Pool({ connectionString: adminUrl, max: 1, connectionTimeoutMillis: 2000 })
admin = new Pool({ connectionString: adminUrl, max: 1, connectionTimeoutMillis: 10000 })
if (process.env.STORE_REVEAL_TEST_POSTGRES_CONTAINER) {
const created = spawnSync('docker', [
'exec', process.env.STORE_REVEAL_TEST_POSTGRES_CONTAINER,
'createdb', '-U', decodeURIComponent(target.username), dbName,
], { encoding: 'utf8' })
if (created.status !== 0) throw new Error('Failed to create isolated local test database')
} else {
await admin.query(`CREATE DATABASE "${dbName}"`)
}
databaseCreated = true
if (process.env.STORE_REVEAL_TEST_POSTGRES_CONTAINER) {
const createdRole = spawnSync('docker', [
'exec', process.env.STORE_REVEAL_TEST_POSTGRES_CONTAINER,
'psql', '-U', decodeURIComponent(target.username), '-d', target.pathname.slice(1),
'-v', 'ON_ERROR_STOP=1', '-c', `CREATE ROLE "${readerName}" NOLOGIN`,
], { encoding: 'utf8' })
if (createdRole.status !== 0) throw new Error('Failed to create isolated local test role')
} else {
await admin.query(`CREATE ROLE "${readerName}" NOLOGIN`)
}
roleCreated = true
target.pathname = `/${dbName}`
db = new Pool({ connectionString: target.toString(), max: 8,
// Enable actual server statement logs in this isolated test session only.
// Never log bind values, even on errors.
options: '-c log_statement=all -c log_parameter_max_length=0 -c log_parameter_max_length_on_error=0 -c log_min_error_statement=panic',
statement_timeout: 3000, connectionTimeoutMillis: 2000,
statement_timeout: 3000, connectionTimeoutMillis: 10000,
})
db.on('connect', client => {
const original = client.query.bind(client)
@ -79,23 +96,38 @@ integration('finance reveal with real PostgreSQL, migration and crypto', () => {
process.env.STORE_ACCOUNT_ENCRYPTION_KEY = randomBytes(32).toString('base64url')
process.env.STORE_ACCOUNT_FINANCE_TOKEN = token
process.env.STORE_ACCOUNT_REVEAL_EDGE_TOKEN = edgeToken
await db.query(`CREATE TABLE public."StoreAccount" (id TEXT PRIMARY KEY, "storeName" TEXT,
"passwordCiphertext" TEXT NOT NULL DEFAULT '', visible BOOLEAN NOT NULL);
await db.query(`CREATE TABLE public."StoreAccount" (
id TEXT PRIMARY KEY, "storeName" TEXT,
"operationOwner" TEXT NOT NULL DEFAULT '', "operationDepartmentHead" TEXT NOT NULL DEFAULT '',
"operationDirector" TEXT NOT NULL DEFAULT '', "profitCenter" TEXT NOT NULL DEFAULT '',
subject TEXT NOT NULL DEFAULT '', "accountType" TEXT NOT NULL DEFAULT '', account TEXT NOT NULL DEFAULT '',
"passwordCiphertext" TEXT NOT NULL DEFAULT '', "boundPhone" TEXT NOT NULL DEFAULT '',
"verificationContact" TEXT NOT NULL DEFAULT '', "jstOrderService" TEXT NOT NULL DEFAULT '',
"accountGroupName" TEXT NOT NULL DEFAULT '', "createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(), visible BOOLEAN NOT NULL);
CREATE SCHEMA external_store_accounts;
CREATE VIEW external_store_accounts.accounts_v1 AS
SELECT id, "storeName" FROM public."StoreAccount" WHERE visible;
SELECT id, "storeName", "operationOwner", "operationDepartmentHead", "operationDirector",
"profitCenter", subject, "accountType", account, "boundPhone", "verificationContact",
"jstOrderService", "accountGroupName", "createdAt", "updatedAt"
FROM public."StoreAccount";
GRANT USAGE ON SCHEMA external_store_accounts TO "${readerName}";
GRANT SELECT ON external_store_accounts.accounts_v1 TO "${readerName}";`)
await db.query(`INSERT INTO public."StoreAccount"
await db.query(`INSERT INTO public."StoreAccount" (id, "storeName", "passwordCiphertext", visible)
SELECT 'synthetic-' || n, 'Synthetic store ' || n,
CASE WHEN n <= 957 THEN $1 ELSE '' END, n <= 958 FROM generate_series(1,960) n`,
CASE WHEN n <= 957 THEN $1 ELSE '' END, true FROM generate_series(1,958) n`,
[encryptStoreAccountPassword(password)])
initialRows = JSON.stringify((await db.query('SELECT * FROM external_store_accounts.accounts_v1 ORDER BY id')).rows)
initialRows = JSON.stringify((await db.query('SELECT id, "storeName" FROM external_store_accounts.accounts_v1 ORDER BY id')).rows)
initialColumns = (await db.query(`SELECT column_name FROM information_schema.columns
WHERE table_schema='external_store_accounts' AND table_name='accounts_v1' ORDER BY ordinal_position`)).rows.map(r => r.column_name)
await db.query(readFileSync(resolve('prisma/migrations/20260908120000_store_account_password_reveal/migration.sql'), 'utf8'))
await db.query(readFileSync(resolve('prisma/migrations/20260909100000_fix_store_account_password_configured_plaintext/migration.sql'), 'utf8'))
const { StoreAccountPasswordRevealService } = jest.requireActual('@/server/services/store-account-password-reveal.service')
mockService = new StoreAccountPasswordRevealService(new StoreAccountPasswordRevealRepository(() => db))
// Fixture creation necessarily binds the synthetic plaintext under the current
// storage rule. The acceptance window begins after setup and covers reveal reads.
sqlContainsPassword = false
consoleContainsPassword = false
}, 20000)
beforeEach(async () => { await db.query('TRUNCATE public."StoreAccountPasswordAccessAudit"') })
@ -108,8 +140,27 @@ integration('finance reveal with real PostgreSQL, migration and crypto', () => {
if (db) await db.end()
if (admin) {
// Names are generated above, never accepted from env or user input.
if (databaseCreated) await admin.query(`DROP DATABASE "${dbName}"`)
if (roleCreated) await admin.query(`DROP ROLE "${readerName}"`)
if (databaseCreated && process.env.STORE_REVEAL_TEST_POSTGRES_CONTAINER) {
const target = new URL(adminUrl!)
const dropped = spawnSync('docker', [
'exec', process.env.STORE_REVEAL_TEST_POSTGRES_CONTAINER,
'dropdb', '-U', decodeURIComponent(target.username), dbName,
], { encoding: 'utf8' })
if (dropped.status !== 0) throw new Error('Failed to remove isolated local test database')
} else if (databaseCreated) {
await admin.query(`DROP DATABASE "${dbName}"`)
}
if (roleCreated && process.env.STORE_REVEAL_TEST_POSTGRES_CONTAINER) {
const target = new URL(adminUrl!)
const droppedRole = spawnSync('docker', [
'exec', process.env.STORE_REVEAL_TEST_POSTGRES_CONTAINER,
'psql', '-U', decodeURIComponent(target.username), '-d', target.pathname.slice(1),
'-v', 'ON_ERROR_STOP=1', '-c', `DROP ROLE "${readerName}"`,
], { encoding: 'utf8' })
if (droppedRole.status !== 0) throw new Error('Failed to remove isolated local test role')
} else if (roleCreated) {
await admin.query(`DROP ROLE "${readerName}"`)
}
await admin.end()
}
})