20260908120000 追加的 passwordConfigured 在生产 958 行全部为 false, 与 DBA 基线 957 true / 1 false 不符。 视图的 FROM 子查询本身就取自 public."StoreAccount",EXISTS 又按 credential.id = original.id 自连接同一行,id 必然命中;唯一失配点是密文正则。 该正则只接受 URL-safe base64([A-Za-z0-9_-])且长度固定,标准 base64 的 + / = 填充会让整体失配,而 Node 的 base64url 解码器对填充宽容, 所以"可解密"与"正则失配"可以同时成立。 本迁移只重写视图定义,不读写任何数据: - 用 replace 换字符集片段(避开 pg_get_viewdef 的反斜杠转义差异) - 幂等,未接入该视图或缺少该列则跳过 - 列名/列序/列类型/行数/ACL 均不变,空值、空串与明文仍判定为 false 已在本地 PostgreSQL 17 复现并验证:带填充密文由 false 变 true, base64url 保持 true,空值/空串/明文垃圾值仍为 false,ACL 与数据无变化。 同步更新 finance-store-account-password-reveal.md 中已过时的 "生产 passwordConfigured 尚未落库" 结论与验收记录。
15 KiB
财务系统店铺密码按需读取契约
状态:代码和本地测试交付;生产迁移、配置、发布及真实接口验收须单独批准。
存储事实与授权边界
- 密码真相源是
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_v1COUNT=958、DISTINCT id=958;957 条非空密文均可在生产主机内存中解密,空值 1、解密失败 0。过程只输出聚合,不导出密码或密文。 - 2026-09-09 外部只读账号复核:
passwordConfigured聚合为 false=958,与上述 957/1/0 基线不符。已定位为20260908120000的判定正则只接受 URL-safe base64,标准 base64 的+、/、=填充会让正则整体失配(Node 的 base64url 解码器对填充宽容,因此"可解密"与"正则失配"并不矛盾)。修正见20260909120000_fix_store_account_password_configured_regex,纯视图 DDL、不读写任何数据,生产执行仍需 DBA 审批。 - 普通
store_account_reader可读视图、不能读取基表或密码列。此次迁移不增加其基表权限。 - 本接口只信任专用服务身份
finance-store-accounts。财务系统必须在自身后端校验用户登录态、查看/复制权限及店铺范围,然后从可信会话中取requesterId。不得把客户端传入的任意人员 ID 当成操作者。服务凭据仅保存在财务后端,禁止交给浏览器。 - 不提供列表、批量密码导出或自动预取。列表只读取
passwordConfigured;每次明确的查看/复制操作单独调用接口。上游审计记录的用户 ID 是专用财务服务的身份断言,未授权请求中的 ID 不可信,serviceId=null。
HTTP 契约
生产配置完成后的地址:
POST https://cp.jiansun.vip/internal/store-accounts/{id}/password/reveal
id 为 accounts_v1.id,仅接受单个 1–128 位字母、数字、下划线或连字符。无查询参数;禁止在 URL、请求体、请求头中传待读取密码。
请求头:
Authorization: Bearer <专用服务令牌>
Content-Type: application/json
请求体(严格拒绝额外字段,最大 1024 字节,1 秒内读取完毕):
{ "requesterId": "finance-user-123", "purpose": "view" }
purpose 仅允许 view 或 copy。requesterId 是财务会话中的稳定人员标识,1–128 位 [A-Za-z0-9_.:@-]。不接受批量 ID、查询参数、普通登录 Cookie/JWT、普通数据库凭据或其他内部任务密钥。
成功 200:
{ "requestId": "<服务端生成的 UUID>", "password": "<仅本次响应体中的密码>" }
所有状态均返回 Cache-Control: no-store、Pragma: no-cache、X-Content-Type-Options: nosniff。X-Request-Id 与响应体、审计的追踪 ID 相同。不会在响应头中放密码。
错误体:
{ "requestId": "<UUID>", "error": { "code": "FORBIDDEN", "message": "请求被拒绝" } }
| HTTP | code | 含义 |
|---|---|---|
| 403 | FORBIDDEN | 服务认证失败、非 HTTPS、非法 ID/JSON/用途、额外参数或非 POST |
| 404 | PASSWORD_NOT_FOUND | 账号不存在或没有可用密码,两者不区分 |
| 429 | RATE_LIMITED | 任一限流窗口超限 |
| 503 | UNAVAILABLE | 密钥/解密、数据库、限流或审计不可用;不返回内部异常 |
审计或数据库故障优先返回 503,绝不在无法持久化成功审计时返回密码。数据库完全不可用时无法保存该次失败审计,由网关仅记录追踪 ID、状态与时间供运维关联。成功审计表示服务器已授权并生成响应,不等同于浏览器已经复制成功。
限流和超时
- 数据库滚动窗口:服务全部账号 30 次/分钟、300 次/24 小时;同一人员 10 次/分钟;同一账号 5 次/分钟。查看和复制共享额度,轮换令牌不重置服务身份或额度。
- 已认证的成功、404、参数拒绝和解密失败均占额度;429 不延长窗口。额度检查与审计在同一数据库事务中用事务级锁串行化,跨进程/重启共享状态。
- 429 不携带剩余数量或精确窗口,避免推测其他用户行为。客户端不得自动重试密码读取;提示用户稍后再试,持续超限联系管理员。
- 财务客户端设置总超时 10 秒、禁用自动重定向和重试。后端池上限 4、连接获取 1 秒、SQL 执行 1.5 秒/客户端查询 2 秒、锁等待 1 秒,路由运行预算 10 秒。网关必须配置请求体 1 秒和总请求截止,防止慢请求占用。
- 入口网关还需按来源 IP 对全部请求(包括未授权请求)限制为 60 次/分钟,返回 429 JSON 和
no-store;这项入口防滥用配置属于上线门禁。应用层数据库限流负责已认证的敏感读取。
环境变量(仅名称及要求)
| 名称 | 所在系统与要求 |
|---|---|
| STORE_ACCOUNT_ENCRYPTION_KEY | 上游已有独立加密密钥,至少 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 字符;与财务令牌完全分离,不提供给财务系统;缺失时拒绝全部调用 |
| DATABASE_URL | 上游现有数据库连接;仅专用不输出查询/异常的连接池读取密文、写审计 |
| STORE_ACCOUNT_REVEAL_URL | 财务后端配置完整 HTTPS 地址模板 |
| STORE_ACCOUNT_REVEAL_TOKEN | 财务后端保存与上游专用令牌对应的值 |
| STORE_REVEAL_TEST_ADMIN_URL | 仅本地集成测试;必须指向回环地址、有建测试库/角色权限的 PostgreSQL |
| STORE_REVEAL_TEST_POSTGRES_CONTAINER | 仅本地日志验收,指定本地 PostgreSQL 容器以只读扫描测试期间日志 |
HTTPS、日志和缓存上线门禁
- 财务后端至公开入口及跨主机的所有代理链路必须使用验证证书的 HTTPS,禁止 HTTP 跳转、跳过证书验证、明文 FRP/跨主机上游。当前 PR 不修改生产 TLS 拓扑;上线前逐跳核实,不满足即不启用令牌。
- 应用不以 URL 或未经认证的转发头证明 TLS。必须由受控网关在实际 HTTPS 入口覆盖
X-Forwarded-Proto: https及X-Store-Reveal-Edge-Token(值来自独立STORE_ACCOUNT_REVEAL_EDGE_TOKEN),剥除客户端原有同名头;HTTP 入口直接拒绝,不能注入上述凭据。应用对网关凭据做定时安全比较,缺失/错误或协议不是 https 时拒绝。凭据仅上游和网关持有,不提供给财务系统;配置文件 0600,不通过nginx -T等诊断输出展示。仍需封闭应用直连并逐跳验证 TLS,独立凭据不代替网络加密。 - 对精确路径
/internal/store-accounts/*/password/reveal禁止网关缓存、响应落盘缓冲、CDN缓存、请求/响应体及 Authorization 采集;包括 APM、WAF、异常上报、链路追踪和调试抓包。网关日志只允许时间、方法、路径(不带参数)、状态、耗时、追踪 ID。不要采集响应对象。 - 网关自己的 403/404/429/503 也使用脱敏 JSON、
Cache-Control: no-store和追踪 ID;不得返回上游诊断页。禁止将该路由接入通用withApi/request logger;目前路由直接返回白名单响应,不记录异常对象。 - 数据库不执行明文解密,SQL 参数不包含明文密码。现有密码写入或密钥轮换时,关闭 PostgreSQL/pgAudit 的绑定参数及错误参数记录,防止记录密文。不要启用该连接池的 SQL 结果或参数追踪。
- 财务后端同样禁用正文采集、缓存及异常对象打印。浏览器仅在用户点击后获取,避免 localStorage/sessionStorage/Service Worker 持久化;显示或复制完清除页面内存状态,不把密码放入通知、埋点或消息队列。
视图与审计迁移
迁移:prisma/migrations/20260908120000_store_account_password_reveal/migration.sql。
- 新增
StoreAccountPasswordAccessAudit,固定字段为请求追踪 ID、人员标识、服务身份、账号 ID、用途、HTTP状态、数据库时间;不关联用户/店铺外键,以保留未授权、不存在和删除账号的失败审计。状态 200 表示成功,其他状态表示失败。 - 只在原视图存在时,以原视图查询作为子查询,在末尾追加
passwordConfigured boolean,保留原字段、筛选、每行内容、ID、所有者与 ACL;EXISTS不会产生重复行。未配置外部视图的环境不擅自创建外部读入口。 passwordConfigured表示存在格式完整的、非空 AES-GCM 密码封装。SQL 不接触密钥,所以不在每次列表查询时验证 GCM 标签/密钥健康;生产预检必须做主机内存中的解密聚合验证。密钥丢失或密文损坏属于 503 运维故障,不能把它伪装成未设置密码。- 判定正则必须同时接受 base64url 与带
=填充的标准 base64。只认 URL-safe 字符集会把全部存量密文误判成"未配置"(20260909120000修正的就是这一点);同时仍保留v1:前缀、三段结构与 IV 长度约束,空值、空串与明文垃圾值依旧判定为 false。 - 无历史明文迁移,无现有密码或银行账号数据变更,无额外只读权限。审计数据增量写入,上线后按安全审计要求保留至少 365 天;此 PR 不创建自动删除任务。
迁移前由 DBA 只读确认目标库、视图定义/所有者/ACL、字段顺序、行数与唯一性、密码聚合、普通读取账号和其成员角色权限。若生产仍以本次基线验收,COUNT 与 DISTINCT id 必须均为 958;若业务新增/删除则暂停并重新确认基线,不改数据凑数。
备份与执行顺序(生产必须在明确批准后执行):
- 备份当前
main/部署 SHA、现有视图 DDL/ACL/所有者及_prisma_migrations状态。生成目标库pg_dump -Fc备份并验证可读取;备份进入生产主机权限 0700 目录,文件 0600,不进入 Git 或共享日志。备份可能含密文,按敏感材料管理。 - 本次不换加密密钥;核实独立密钥在受限凭据存储中有可恢复副本,不在命令行、文档中显示。记录备份校验和、时间及恢复演练证据。
- 保持新财务令牌未配置,执行批准的该迁移,视图短时获得 DDL 锁(最多等 5 秒,执行预算 30 秒),新增审计表及索引;不回放未审查的其他迁移。
- 在同一只读快照比较迁移前后原字段行集、顺序/类型、行数/唯一性和权限;运行
scripts/sql/check-store-account-password-reveal.sql,确认最后字段 boolean,报告 true/false/null 聚合。预计当前数据为 957/1/0,但必须执行后实测。 - 按项目主线发布流程部署,完成逐跳 TLS、网关日志/限流/缓存检查后再配令牌。生产虚构账号的创建、调用与删除属于额外生产写入,需单独批准;不要调用真实账号作为测试。
回滚:先移除财务令牌阻止新调用并回退应用 SHA。新增状态列与审计表可安全保留以避免丢失审计;旧应用不依赖它们。需要完整结构回退时,经 DBA 检查依赖后,在事务中以备份 DDL 重建旧视图并恢复原所有者/ACL(PostgreSQL 不允许 CREATE OR REPLACE VIEW 删除尾列,禁止随意 DROP ... CASCADE)。审计记录保留,不直接删表;按项目规则处理已应用迁移历史,禁止直接删除 _prisma_migrations 记录或用 db push。任何完整结构回退先在隔离库演练。
密钥轮换
服务令牌:在受限凭据系统生成新随机令牌;旧值暂放 STORE_ACCOUNT_FINANCE_TOKEN_PREVIOUS,新值放主变量,重启上游;更新财务后端,使用虚构账号验收;移除旧值并重启。两值对应同一服务身份,审计/额度不变。怀疑泄露时立即撤销旧值,宁可短暂拒绝请求。
网关凭据:暂停此接口,在网关及上游同时更新 STORE_ACCOUNT_REVEAL_EDGE_TOKEN 后验收并恢复;不支持旧值并行。不得将网关凭据与服务令牌设成相同值。
数据加密密钥:现有 v1: 不含 key ID,不能直接替换环境变量。必须另行批准维护窗口,暂停所有店铺密码及银行账号的写入/读取,备份对应表和旧密钥;用受控迁移进程在内存中旧钥解密、新钥重新 AES-GCM 加密,并在同一数据库事务中更新两类密文字段(SQL 永不接收明文)。验证全量解密聚合、行数及虚构账号后,协调所有应用实例切换新环境密钥,再恢复流量;失败时同时恢复密文备份和旧钥。关闭 SQL 参数/异常参数、APM及备份内容输出;本 PR 不执行此轮换,也不引入第二套密钥或把新旧密钥存入数据库。需要无停机轮换时,应另行实现带 key ID 的 Envelope Encryption/KMS 版本迁移。
本次验证记录
- 单元/HTTP/代理路由测试:34 项通过,含伪造 HTTPS URL/转发头和错误网关凭据拒绝。
- 追加现有代理路由的 7 项回归,与 10 项集成测试合计 6 套件、51 项全部通过。
- 隔离本地 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已随20260908120000落库,但当前 958 行全部判定为 false,修正迁移20260909120000已交付、待 DBA 审批执行。修正后应复跑scripts/sql/check-store-account-password-reveal.sql,预期回到 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拓扑/网关配置及发布后真实验收。