feat(store-accounts): add audited finance password reveal
This commit is contained in:
parent
e9f83b16ee
commit
494a1cf60f
@ -0,0 +1,131 @@
|
||||
# 财务系统店铺密码按需读取契约
|
||||
|
||||
状态:代码和本地测试交付;生产迁移、配置、发布及真实接口验收须单独批准。
|
||||
|
||||
## 存储事实与授权边界
|
||||
|
||||
- 密码真相源是 `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。过程只输出聚合,不导出密码或密文。
|
||||
- 普通 `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、请求体、请求头中传待读取密码。
|
||||
|
||||
请求头:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <专用服务令牌>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
请求体(严格拒绝额外字段,最大 1024 字节,1 秒内读取完毕):
|
||||
|
||||
```json
|
||||
{ "requesterId": "finance-user-123", "purpose": "view" }
|
||||
```
|
||||
|
||||
`purpose` 仅允许 `view` 或 `copy`。`requesterId` 是财务会话中的稳定人员标识,1–128 位 `[A-Za-z0-9_.:@-]`。不接受批量 ID、查询参数、普通登录 Cookie/JWT、普通数据库凭据或其他内部任务密钥。
|
||||
|
||||
成功 `200`:
|
||||
|
||||
```json
|
||||
{ "requestId": "<服务端生成的 UUID>", "password": "<仅本次响应体中的密码>" }
|
||||
```
|
||||
|
||||
所有状态均返回 `Cache-Control: no-store`、`Pragma: no-cache`、`X-Content-Type-Options: nosniff`。`X-Request-Id` 与响应体、审计的追踪 ID 相同。不会在响应头中放密码。
|
||||
|
||||
错误体:
|
||||
|
||||
```json
|
||||
{ "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、日志和缓存上线门禁
|
||||
|
||||
1. 财务后端至公开入口及跨主机的所有代理链路必须使用验证证书的 HTTPS,禁止 HTTP 跳转、跳过证书验证、明文 FRP/跨主机上游。当前 PR 不修改生产 TLS 拓扑;上线前逐跳核实,不满足即不启用令牌。
|
||||
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 结果或参数追踪。
|
||||
6. 财务后端同样禁用正文采集、缓存及异常对象打印。浏览器仅在用户点击后获取,避免 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 运维故障,不能把它伪装成未设置密码。
|
||||
- 无历史明文迁移,无现有密码或银行账号数据变更,无额外只读权限。审计数据增量写入,上线后按安全审计要求保留至少 365 天;此 PR 不创建自动删除任务。
|
||||
|
||||
迁移前由 DBA 只读确认目标库、视图定义/所有者/ACL、字段顺序、行数与唯一性、密码聚合、普通读取账号和其成员角色权限。若生产仍以本次基线验收,COUNT 与 DISTINCT id 必须均为 958;若业务新增/删除则暂停并重新确认基线,不改数据凑数。
|
||||
|
||||
备份与执行顺序(生产必须在明确批准后执行):
|
||||
|
||||
1. 备份当前 `main`/部署 SHA、现有视图 DDL/ACL/所有者及 `_prisma_migrations` 状态。生成目标库 `pg_dump -Fc` 备份并验证可读取;备份进入生产主机权限 0700 目录,文件 0600,不进入 Git 或共享日志。备份可能含密文,按敏感材料管理。
|
||||
2. 本次不换加密密钥;核实独立密钥在受限凭据存储中有可恢复副本,不在命令行、文档中显示。记录备份校验和、时间及恢复演练证据。
|
||||
3. 保持新财务令牌未配置,执行批准的该迁移,视图短时获得 DDL 锁(最多等 5 秒,执行预算 30 秒),新增审计表及索引;不回放未审查的其他迁移。
|
||||
4. 在同一只读快照比较迁移前后原字段行集、顺序/类型、行数/唯一性和权限;运行 `scripts/sql/check-store-account-password-reveal.sql`,确认最后字段 boolean,报告 true/false/null 聚合。预计当前数据为 957/1/0,但必须执行后实测。
|
||||
5. 按项目主线发布流程部署,完成逐跳 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` 尚未落库,生产接口尚未发布,因此没有生产新增字段/新接口的验收结论。
|
||||
|
||||
运行:`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拓扑/网关配置及发布后真实验收。
|
||||
@ -0,0 +1,49 @@
|
||||
BEGIN;
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
SET LOCAL statement_timeout = '30s';
|
||||
|
||||
CREATE TABLE public."StoreAccountPasswordAccessAudit" (
|
||||
"requestId" UUID PRIMARY KEY,
|
||||
"requesterId" VARCHAR(128),
|
||||
"serviceId" VARCHAR(64),
|
||||
"accountId" VARCHAR(128),
|
||||
purpose VARCHAR(8) NOT NULL CHECK (purpose IN ('view', 'copy', 'unknown')),
|
||||
status INTEGER NOT NULL CHECK (status IN (200, 403, 404, 429, 503)),
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT clock_timestamp()
|
||||
);
|
||||
CREATE INDEX "StoreAccountPasswordAccessAudit_serviceId_createdAt_idx"
|
||||
ON public."StoreAccountPasswordAccessAudit" ("serviceId", "createdAt");
|
||||
CREATE INDEX "StoreAccountPasswordAccessAudit_accountId_createdAt_idx"
|
||||
ON public."StoreAccountPasswordAccessAudit" ("accountId", "createdAt");
|
||||
REVOKE ALL ON public."StoreAccountPasswordAccessAudit" FROM PUBLIC;
|
||||
|
||||
DO $migration$
|
||||
DECLARE
|
||||
original_definition TEXT;
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'store_account_reader') THEN
|
||||
REVOKE ALL ON public."StoreAccountPasswordAccessAudit" FROM store_account_reader;
|
||||
END IF;
|
||||
-- The external view is provisioned separately by the DBA. Do not create it or
|
||||
-- broaden its grants in environments that have not opted into this integration.
|
||||
IF to_regclass('external_store_accounts.accounts_v1') IS NULL THEN RETURN; END IF;
|
||||
IF EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'external_store_accounts' AND table_name = 'accounts_v1'
|
||||
AND column_name = 'passwordConfigured') THEN
|
||||
RAISE EXCEPTION 'External account contract needs preflight review';
|
||||
END IF;
|
||||
original_definition := rtrim(pg_get_viewdef('external_store_accounts.accounts_v1'::regclass, true), E';\n\r ');
|
||||
-- Keep the original query, projection, row filters, duplicates (if any), owner
|
||||
-- and ACL. EXISTS does not multiply rows. No secret is exposed in this view.
|
||||
EXECUTE format($view$
|
||||
CREATE OR REPLACE VIEW external_store_accounts.accounts_v1 AS
|
||||
SELECT original.*, EXISTS (
|
||||
SELECT 1 FROM public."StoreAccount" credential
|
||||
WHERE credential.id = original.id
|
||||
AND credential."passwordCiphertext" ~ '^v1:[A-Za-z0-9_-]{16}\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{2,}$'
|
||||
AND length(split_part(credential."passwordCiphertext", '.', 3)) %% 4 <> 1
|
||||
)::boolean AS "passwordConfigured"
|
||||
FROM (%s) original
|
||||
$view$, original_definition);
|
||||
END $migration$;
|
||||
COMMIT;
|
||||
@ -3597,6 +3597,20 @@ model StoreAccount {
|
||||
@@index([updatedAt])
|
||||
}
|
||||
|
||||
// Dedicated append-only access audit. Never store password/ciphertext/request bodies.
|
||||
model StoreAccountPasswordAccessAudit {
|
||||
requestId String @id @db.Uuid
|
||||
requesterId String? @db.VarChar(128)
|
||||
serviceId String? @db.VarChar(64)
|
||||
accountId String? @db.VarChar(128)
|
||||
purpose String @db.VarChar(8)
|
||||
status Int
|
||||
createdAt DateTime @default(dbgenerated("clock_timestamp()")) @db.Timestamptz(3)
|
||||
|
||||
@@index([serviceId, createdAt])
|
||||
@@index([accountId, createdAt])
|
||||
}
|
||||
|
||||
model StoreAccountStoreProfile {
|
||||
id String @id @default(cuid())
|
||||
storeName String
|
||||
|
||||
@ -0,0 +1,20 @@
|
||||
-- Post-migration read-only acceptance. Contains no credential material or row data.
|
||||
BEGIN READ ONLY;
|
||||
SET LOCAL statement_timeout = '10s';
|
||||
SELECT jsonb_build_object(
|
||||
'database', current_database(),
|
||||
'count', count(*),
|
||||
'distinctIds', count(DISTINCT id),
|
||||
'passwordConfiguredTrue', count(*) FILTER (WHERE "passwordConfigured" IS TRUE),
|
||||
'passwordConfiguredFalse', count(*) FILTER (WHERE "passwordConfigured" IS FALSE),
|
||||
'passwordConfiguredNull', count(*) FILTER (WHERE "passwordConfigured" IS NULL),
|
||||
'type', (SELECT data_type FROM information_schema.columns
|
||||
WHERE table_schema='external_store_accounts' AND table_name='accounts_v1' AND column_name='passwordConfigured'),
|
||||
'isLastColumn', (SELECT column_name='passwordConfigured' FROM information_schema.columns
|
||||
WHERE table_schema='external_store_accounts' AND table_name='accounts_v1' ORDER BY ordinal_position DESC LIMIT 1),
|
||||
'readerCanReadView', has_table_privilege('store_account_reader','external_store_accounts.accounts_v1','SELECT'),
|
||||
'readerCanReadBase', has_table_privilege('store_account_reader','public."StoreAccount"','SELECT'),
|
||||
'readerCanReadPassword', has_column_privilege('store_account_reader','public."StoreAccount"','passwordCiphertext','SELECT'),
|
||||
'readerCanReadAudit', has_table_privilege('store_account_reader','public."StoreAccountPasswordAccessAudit"','SELECT')
|
||||
) FROM external_store_accounts.accounts_v1;
|
||||
ROLLBACK;
|
||||
@ -0,0 +1,88 @@
|
||||
/** @jest-environment node */
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { POST, GET } from './route'
|
||||
|
||||
jest.mock('server-only', () => ({}))
|
||||
const mockReveal = jest.fn()
|
||||
jest.mock('@/server/services/store-account-password-reveal.service', () => ({
|
||||
StoreAccountPasswordRevealService: jest.fn().mockImplementation(() => ({ reveal: (...args: unknown[]) => mockReveal(...args) })),
|
||||
}))
|
||||
|
||||
const path = 'https://example.test/internal/store-accounts/test-id/password/reveal'
|
||||
const context = { params: Promise.resolve({ id: 'test-id' }) }
|
||||
function request(url = path, body = JSON.stringify({ requesterId: 'u', purpose: 'view' })) {
|
||||
return new Request(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body })
|
||||
}
|
||||
|
||||
describe('secret HTTP response boundary', () => {
|
||||
beforeEach(() => { mockReveal.mockReset(); delete process.env.STORE_ACCOUNT_REVEAL_EDGE_TOKEN })
|
||||
afterAll(() => { delete process.env.STORE_ACCOUNT_REVEAL_EDGE_TOKEN })
|
||||
|
||||
it.each([200, 403, 404, 429, 503])('sets no-store for status %i and places no credential in headers', async status => {
|
||||
const password = randomBytes(24).toString('base64url')
|
||||
mockReveal.mockResolvedValue(status === 200 ? { status, password } : { status })
|
||||
const response = await POST(request(), context)
|
||||
expect(response.status).toBe(status)
|
||||
expect(response.headers.get('cache-control')).toBe('no-store')
|
||||
expect(JSON.stringify([...response.headers]).includes(password)).toBe(false)
|
||||
const json = await response.json()
|
||||
expect(typeof json.requestId).toBe('string')
|
||||
expect(json.password === password).toBe(status === 200)
|
||||
expect('password' in json).toBe(status === 200)
|
||||
})
|
||||
|
||||
it('does not trust spoofed forwarded HTTPS by default', async () => {
|
||||
mockReveal.mockResolvedValue({ status: 403 })
|
||||
const req = request(path.replace('https:', 'http:'))
|
||||
req.headers.set('x-forwarded-proto', 'https')
|
||||
await POST(req, context)
|
||||
expect(mockReveal.mock.calls[0][0].secure).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts HTTPS metadata only from an authenticated TLS edge', async () => {
|
||||
const edgeToken = randomBytes(32).toString('base64url')
|
||||
process.env.STORE_ACCOUNT_REVEAL_EDGE_TOKEN = edgeToken
|
||||
mockReveal.mockResolvedValue({ status: 403 })
|
||||
const req = request(path.replace('https:', 'http:'))
|
||||
req.headers.set('x-forwarded-proto', 'https')
|
||||
req.headers.set('x-store-reveal-edge-token', edgeToken)
|
||||
await POST(req, context)
|
||||
expect(mockReveal.mock.calls[0][0].secure).toBe(true)
|
||||
})
|
||||
|
||||
it('does not treat a forwarded HTTPS URL as proof of a trusted TLS socket', async () => {
|
||||
process.env.STORE_ACCOUNT_REVEAL_EDGE_TOKEN = randomBytes(32).toString('base64url')
|
||||
mockReveal.mockResolvedValue({ status: 403 })
|
||||
await POST(request(), context)
|
||||
expect(mockReveal.mock.calls[0][0].secure).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a wrong edge credential or an insecure edge assertion', async () => {
|
||||
const edgeToken = randomBytes(32).toString('base64url')
|
||||
process.env.STORE_ACCOUNT_REVEAL_EDGE_TOKEN = edgeToken
|
||||
mockReveal.mockResolvedValue({ status: 403 })
|
||||
const req = request()
|
||||
req.headers.set('x-forwarded-proto', 'https')
|
||||
req.headers.set('x-store-reveal-edge-token', randomBytes(32).toString('base64url'))
|
||||
await POST(req, context)
|
||||
expect(mockReveal.mock.calls[0][0].secure).toBe(false)
|
||||
req.headers.set('x-store-reveal-edge-token', edgeToken)
|
||||
req.headers.set('x-forwarded-proto', 'http')
|
||||
await POST(req, context)
|
||||
expect(mockReveal.mock.calls[1][0].secure).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects any query string and does not accept GET as a reveal action', async () => {
|
||||
mockReveal.mockResolvedValue({ status: 403 })
|
||||
await POST(request(path + '?purpose=copy'), context)
|
||||
expect(mockReveal.mock.calls[0][0].secure).toBe(false)
|
||||
await GET(new Request(path), context)
|
||||
expect(mockReveal.mock.calls[1][0].post).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['{', 'a'.repeat(1025)])('bounds malformed and oversized request bodies (%#)', async body => {
|
||||
mockReveal.mockResolvedValue({ status: 403 })
|
||||
await POST(request(path, body), context)
|
||||
expect(mockReveal.mock.calls[0][0].body).toBeNull()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,65 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { StoreAccountPasswordRevealService } from '@/server/services/store-account-password-reveal.service'
|
||||
import { hasTrustedRevealTransport } from '@/server/services/store-account-password-reveal-transport'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 10
|
||||
const service = new StoreAccountPasswordRevealService()
|
||||
const errors = {
|
||||
403: ['FORBIDDEN', '请求被拒绝'],
|
||||
404: ['PASSWORD_NOT_FOUND', '账号或可用密码不存在'],
|
||||
429: ['RATE_LIMITED', '请求过于频繁'],
|
||||
503: ['UNAVAILABLE', '服务暂不可用'],
|
||||
} as const
|
||||
|
||||
async function readBody(request: Request): Promise<unknown> {
|
||||
if (request.method !== 'POST' || !/^application\/json(?:\s*;|$)/i.test(request.headers.get('content-type') || '')) return null
|
||||
const reader = request.body?.getReader()
|
||||
if (!reader) return null
|
||||
let timedOut = false
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true
|
||||
void reader.cancel().catch(() => {})
|
||||
}, 1000)
|
||||
try {
|
||||
const chunks: Uint8Array[] = []
|
||||
let size = 0
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
size += value.byteLength
|
||||
if (size > 1024) { await reader.cancel(); return null }
|
||||
chunks.push(value)
|
||||
}
|
||||
return timedOut ? null : JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
||||
} catch { return null } finally { clearTimeout(timeout); reader.releaseLock() }
|
||||
}
|
||||
|
||||
export async function POST(request: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const requestId = randomUUID()
|
||||
const url = new URL(request.url)
|
||||
const secure = hasTrustedRevealTransport(request.headers)
|
||||
const result = await service.reveal({
|
||||
accountId: (await context.params).id, body: await readBody(request),
|
||||
authorization: request.headers.get('authorization'),
|
||||
secure: secure && !url.search, post: request.method === 'POST', requestId,
|
||||
})
|
||||
const headers = {
|
||||
'Cache-Control': 'no-store', 'Pragma': 'no-cache',
|
||||
'X-Content-Type-Options': 'nosniff', 'X-Request-Id': requestId,
|
||||
'Strict-Transport-Security': 'max-age=31536000',
|
||||
}
|
||||
if (result.status === 200) {
|
||||
return Response.json({ requestId, password: result.password }, { status: 200, headers })
|
||||
}
|
||||
const [code, message] = errors[result.status as keyof typeof errors]
|
||||
return Response.json({ requestId, error: { code, message } }, { status: result.status, headers })
|
||||
}
|
||||
|
||||
export const GET = POST
|
||||
export const PUT = POST
|
||||
export const PATCH = POST
|
||||
export const DELETE = POST
|
||||
export const OPTIONS = POST
|
||||
export const HEAD = POST
|
||||
15
furniture-3d-system/src/proxy-store-account-reveal.test.ts
Normal file
15
furniture-3d-system/src/proxy-store-account-reveal.test.ts
Normal file
@ -0,0 +1,15 @@
|
||||
/** @jest-environment node */
|
||||
import { NextRequest } from 'next/server'
|
||||
import { proxy } from './proxy'
|
||||
|
||||
describe('finance endpoint proxy routing', () => {
|
||||
it('passes the exact endpoint to its service authentication without a login redirect', async () => {
|
||||
const response = await proxy(new NextRequest('https://example.test/internal/store-accounts/test-id/password/reveal'))
|
||||
expect(response.headers.get('location')).toBeNull()
|
||||
expect(response.headers.get('x-middleware-next')).toBe('1')
|
||||
})
|
||||
it('keeps other internal account paths behind browser authentication', async () => {
|
||||
const response = await proxy(new NextRequest('https://example.test/internal/store-accounts/test-id'))
|
||||
expect(new URL(response.headers.get('location')!).pathname).toBe('/login')
|
||||
})
|
||||
})
|
||||
@ -81,6 +81,12 @@ interface WorkspaceTokenPayload {
|
||||
export async function proxy(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl
|
||||
|
||||
// This exact machine endpoint authenticates its dedicated finance identity itself.
|
||||
// Never redirect service credentials into the browser login flow.
|
||||
if (/^\/internal\/store-accounts\/[^/]+\/password\/reveal\/?$/.test(pathname)) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
if (
|
||||
OA_BOARD_LOCAL_PREVIEW_ENABLED &&
|
||||
(pathname === '/intake/oa-board' || pathname.startsWith('/intake/oa-board/'))
|
||||
|
||||
@ -0,0 +1,80 @@
|
||||
import 'server-only'
|
||||
import { Pool, type PoolClient } from 'pg'
|
||||
import {
|
||||
REVEAL_LIMITS,
|
||||
type RevealAudit, type RevealRepository, type RevealTransaction,
|
||||
} from '@/server/services/store-account-password-reveal.contract'
|
||||
|
||||
// Dedicated, uninstrumented pool: neither query results nor driver errors are logged.
|
||||
// Passwords are SELECT results only, never SQL parameters or database-side plaintext.
|
||||
let pool: Pool | undefined
|
||||
function getPool() {
|
||||
if (!pool) {
|
||||
pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
max: 4, connectionTimeoutMillis: 1000, idleTimeoutMillis: 10000,
|
||||
statement_timeout: 1500, query_timeout: 2000,
|
||||
application_name: 'store-password-reveal',
|
||||
})
|
||||
pool.on('error', () => { /* Never emit driver diagnostics on this secret path. */ })
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
class Transaction implements RevealTransaction {
|
||||
constructor(private readonly client: PoolClient) {}
|
||||
|
||||
async limited(event: RevealAudit) {
|
||||
// Serializes the decision AND its audit across all processes/instances.
|
||||
await this.client.query('SELECT pg_advisory_xact_lock(1937010546, 1)')
|
||||
const { rows: [counts] } = await this.client.query<{
|
||||
serviceMinute: number; serviceDay: number; requesterMinute: number; accountMinute: number
|
||||
}>(`SELECT
|
||||
count(*) FILTER (WHERE "createdAt" > clock_timestamp() - interval '1 minute')::int AS "serviceMinute",
|
||||
count(*)::int AS "serviceDay",
|
||||
count(*) FILTER (WHERE "createdAt" > clock_timestamp() - interval '1 minute' AND "requesterId" = $2)::int AS "requesterMinute",
|
||||
count(*) FILTER (WHERE "createdAt" > clock_timestamp() - interval '1 minute' AND "accountId" = $3)::int AS "accountMinute"
|
||||
FROM public."StoreAccountPasswordAccessAudit"
|
||||
WHERE "serviceId" = $1 AND status <> 429 AND "createdAt" > clock_timestamp() - interval '24 hours'`,
|
||||
[event.serviceId, event.requesterId, event.accountId])
|
||||
return (Object.keys(REVEAL_LIMITS) as Array<keyof typeof REVEAL_LIMITS>)
|
||||
.some(key => counts[key] >= REVEAL_LIMITS[key])
|
||||
}
|
||||
|
||||
async findPassword(accountId: string) {
|
||||
const { rows } = await this.client.query<{ passwordCiphertext: string }>(
|
||||
'SELECT "passwordCiphertext" FROM public."StoreAccount" WHERE id = $1', [accountId],
|
||||
)
|
||||
return rows[0]?.passwordCiphertext || null
|
||||
}
|
||||
|
||||
async audit(event: RevealAudit) {
|
||||
// Explicit allowlist: never spread request, response, account record or exception.
|
||||
await this.client.query(`INSERT INTO public."StoreAccountPasswordAccessAudit"
|
||||
("requestId", "requesterId", "serviceId", "accountId", purpose, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
[event.requestId, event.requesterId, event.serviceId, event.accountId, event.purpose, event.status])
|
||||
}
|
||||
}
|
||||
|
||||
export class StoreAccountPasswordRevealRepository implements RevealRepository {
|
||||
constructor(private readonly poolFactory: () => Pool = getPool) {}
|
||||
|
||||
async transaction<T>(run: (tx: RevealTransaction) => Promise<T>): Promise<T> {
|
||||
const client = await this.poolFactory().connect()
|
||||
let broken = false
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
await client.query("SET LOCAL lock_timeout = '1000ms'")
|
||||
const result = await run(new Transaction(client))
|
||||
await client.query('COMMIT')
|
||||
return result
|
||||
} catch {
|
||||
try { await client.query('ROLLBACK') } catch { broken = true }
|
||||
// No error cause: it might contain driver parameters or a crypto value.
|
||||
throw new Error('PASSWORD_REVEAL_UNAVAILABLE')
|
||||
} finally {
|
||||
client.release(broken)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
import 'server-only'
|
||||
import { createHash, timingSafeEqual } from 'node:crypto'
|
||||
|
||||
// Next.js can construct Request.url from forwarded headers, so its URL protocol
|
||||
// cannot prove socket TLS. Only the trusted TLS edge may attest the transport.
|
||||
// The financial service must never receive this independent edge credential.
|
||||
export function hasTrustedRevealTransport(headers: Headers) {
|
||||
const expected = process.env.STORE_ACCOUNT_REVEAL_EDGE_TOKEN
|
||||
const received = headers.get('x-store-reveal-edge-token')
|
||||
if (expected === process.env.STORE_ACCOUNT_FINANCE_TOKEN ||
|
||||
expected === process.env.STORE_ACCOUNT_FINANCE_TOKEN_PREVIOUS) return false
|
||||
if (!expected || !received || !/^[A-Za-z0-9_-]{43,128}$/.test(expected) ||
|
||||
!/^[A-Za-z0-9_-]{43,128}$/.test(received) || headers.get('x-forwarded-proto') !== 'https') return false
|
||||
const digest = (value: string) => createHash('sha256').update(value).digest()
|
||||
return timingSafeEqual(digest(expected), digest(received))
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const revealInputSchema = z.object({
|
||||
requesterId: z.string().regex(/^[A-Za-z0-9_.:@-]{1,128}$/),
|
||||
purpose: z.enum(['view', 'copy']),
|
||||
}).strict()
|
||||
export const accountIdSchema = z.string().regex(/^[A-Za-z0-9_-]{1,128}$/)
|
||||
|
||||
export const FINANCE_SERVICE_ID = 'finance-store-accounts'
|
||||
export const REVEAL_LIMITS = { serviceMinute: 30, serviceDay: 300, requesterMinute: 10, accountMinute: 5 } as const
|
||||
export type RevealInput = z.infer<typeof revealInputSchema>
|
||||
export type RevealStatus = 200 | 403 | 404 | 429 | 503
|
||||
export type RevealAudit = {
|
||||
requestId: string
|
||||
requesterId: string | null
|
||||
serviceId: string | null
|
||||
accountId: string | null
|
||||
purpose: 'view' | 'copy' | 'unknown'
|
||||
status: RevealStatus
|
||||
}
|
||||
export type RevealResult = { status: RevealStatus; password?: string }
|
||||
export interface RevealTransaction {
|
||||
limited(audit: RevealAudit): Promise<boolean>
|
||||
findPassword(accountId: string): Promise<string | null>
|
||||
audit(event: RevealAudit): Promise<void>
|
||||
}
|
||||
export interface RevealRepository {
|
||||
transaction<T>(run: (tx: RevealTransaction) => Promise<T>): Promise<T>
|
||||
}
|
||||
@ -0,0 +1,111 @@
|
||||
/** @jest-environment node */
|
||||
import { randomBytes, randomUUID } from 'node:crypto'
|
||||
import { encryptStoreAccountPassword } from '@/lib/store-account-crypto'
|
||||
import { StoreAccountPasswordRevealService } from './store-account-password-reveal.service'
|
||||
import type { RevealAudit, RevealTransaction } from './store-account-password-reveal.contract'
|
||||
|
||||
jest.mock('server-only', () => ({}))
|
||||
|
||||
describe('finance password reveal security boundary', () => {
|
||||
const token = randomBytes(32).toString('base64url')
|
||||
const syntheticPassword = randomBytes(24).toString('base64url')
|
||||
let tx: jest.Mocked<RevealTransaction>
|
||||
let events: RevealAudit[]
|
||||
let service: StoreAccountPasswordRevealService
|
||||
const request = (overrides = {}) => ({
|
||||
accountId: 'synthetic-account', body: { requesterId: 'finance-user-1', purpose: 'view' },
|
||||
authorization: `Bearer ${token}`, secure: true, post: true, requestId: randomUUID(), ...overrides,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.STORE_ACCOUNT_ENCRYPTION_KEY = randomBytes(32).toString('base64url')
|
||||
process.env.STORE_ACCOUNT_FINANCE_TOKEN = token
|
||||
delete process.env.STORE_ACCOUNT_FINANCE_TOKEN_PREVIOUS
|
||||
events = []
|
||||
tx = {
|
||||
limited: jest.fn().mockResolvedValue(false),
|
||||
findPassword: jest.fn().mockResolvedValue(encryptStoreAccountPassword(syntheticPassword)),
|
||||
audit: jest.fn(async event => { events.push(event) }),
|
||||
}
|
||||
service = new StoreAccountPasswordRevealService({ transaction: run => run(tx) })
|
||||
})
|
||||
afterAll(() => {
|
||||
delete process.env.STORE_ACCOUNT_ENCRYPTION_KEY
|
||||
delete process.env.STORE_ACCOUNT_FINANCE_TOKEN
|
||||
delete process.env.STORE_ACCOUNT_FINANCE_TOKEN_PREVIOUS
|
||||
})
|
||||
|
||||
it.each(['view', 'copy'])('decrypts a single synthetic account for %s and audits only metadata', async purpose => {
|
||||
const result = await service.reveal(request({ body: { requesterId: 'finance-user-1', purpose } }))
|
||||
expect(result.status).toBe(200)
|
||||
// Boolean assertion prevents even a failed test diff from printing secrets.
|
||||
expect(result.password === syntheticPassword).toBe(true)
|
||||
expect(tx.findPassword).toHaveBeenCalledWith('synthetic-account')
|
||||
expect(events).toHaveLength(1)
|
||||
expect(Object.keys(events[0]).sort()).toEqual(['accountId', 'purpose', 'requestId', 'requesterId', 'serviceId', 'status'])
|
||||
expect(events[0].serviceId).toBe('finance-store-accounts')
|
||||
expect(events[0].status).toBe(200)
|
||||
expect(JSON.stringify(events).includes(syntheticPassword)).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ authorization: null }, { authorization: 'Bearer user-session' }, { secure: false }, { post: false },
|
||||
{ accountId: ['a', 'b'] }, { accountId: 'a,b' }, { accountId: '../a' },
|
||||
{ body: { requesterId: 'u', purpose: 'export' } },
|
||||
{ body: { requesterId: 'u', purpose: 'view', ids: ['a', 'b'] } },
|
||||
{ body: { purpose: 'view' } }, { body: null },
|
||||
])('rejects malformed, bulk, insecure or unauthorized input without selecting a credential (%#)', async overrides => {
|
||||
const result = await service.reveal(request(overrides))
|
||||
expect(result.status).toBe(403)
|
||||
expect(result.password === undefined).toBe(true)
|
||||
expect(tx.findPassword).not.toHaveBeenCalled()
|
||||
expect(events[0].status).toBe(403)
|
||||
})
|
||||
|
||||
it('fails closed when credentials are unconfigured', async () => {
|
||||
delete process.env.STORE_ACCOUNT_FINANCE_TOKEN
|
||||
expect((await service.reveal(request())).status).toBe(403)
|
||||
expect(tx.findPassword).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts the previous dedicated token only during rotation', async () => {
|
||||
process.env.STORE_ACCOUNT_FINANCE_TOKEN_PREVIOUS = token
|
||||
process.env.STORE_ACCOUNT_FINANCE_TOKEN = randomBytes(32).toString('base64url')
|
||||
expect((await service.reveal(request())).status).toBe(200)
|
||||
delete process.env.STORE_ACCOUNT_FINANCE_TOKEN_PREVIOUS
|
||||
expect((await service.reveal(request())).status).toBe(403)
|
||||
})
|
||||
|
||||
it.each([null, ''])('returns the same 404 for missing or empty passwords', async value => {
|
||||
tx.findPassword.mockResolvedValue(value)
|
||||
expect((await service.reveal(request())).status).toBe(404)
|
||||
expect(events[0].status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 429 before reading or decrypting when over the limit', async () => {
|
||||
tx.limited.mockResolvedValue(true)
|
||||
expect((await service.reveal(request())).status).toBe(429)
|
||||
expect(tx.findPassword).not.toHaveBeenCalled()
|
||||
expect(events[0].status).toBe(429)
|
||||
})
|
||||
|
||||
it('suppresses sensitive exceptions and fails closed when audit is unavailable', async () => {
|
||||
const logs = ['log', 'warn', 'error', 'info', 'debug'].map(method =>
|
||||
jest.spyOn(console, method as 'log').mockImplementation(() => {}))
|
||||
try {
|
||||
tx.audit.mockRejectedValue(new Error(syntheticPassword))
|
||||
const result = await service.reveal(request())
|
||||
expect(result.status).toBe(503)
|
||||
expect(result.password === undefined).toBe(true)
|
||||
expect(logs.every(log => log.mock.calls.length === 0)).toBe(true)
|
||||
} finally { logs.forEach(log => log.mockRestore()) }
|
||||
})
|
||||
|
||||
it('records decryption failure without including the exception or credential', async () => {
|
||||
tx.findPassword.mockResolvedValue('invalid-envelope')
|
||||
const result = await service.reveal(request())
|
||||
expect(result.status).toBe(503)
|
||||
expect(events[0].status).toBe(503)
|
||||
expect(JSON.stringify(events).includes('invalid-envelope')).toBe(false)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,71 @@
|
||||
import 'server-only'
|
||||
import { createHash, timingSafeEqual } from 'node:crypto'
|
||||
import { decryptStoreAccountPassword } from '@/lib/store-account-crypto'
|
||||
import { StoreAccountPasswordRevealRepository } from '@/server/repositories/store-account-password-reveal.repository'
|
||||
import {
|
||||
accountIdSchema, FINANCE_SERVICE_ID, revealInputSchema,
|
||||
type RevealAudit, type RevealRepository, type RevealResult,
|
||||
} from './store-account-password-reveal.contract'
|
||||
|
||||
function authenticate(authorization: string | null) {
|
||||
const received = authorization?.match(/^Bearer ([A-Za-z0-9_-]{43,128})$/)?.[1]
|
||||
const primary = process.env.STORE_ACCOUNT_FINANCE_TOKEN
|
||||
if (!received || !primary || !/^[A-Za-z0-9_-]{43,128}$/.test(primary)) return false
|
||||
const digest = (s: string) => createHash('sha256').update(s).digest()
|
||||
const candidate = digest(received)
|
||||
// Only these dedicated credentials map to this identity. Session/JWT/DB tokens do not.
|
||||
const tokens = [primary, process.env.STORE_ACCOUNT_FINANCE_TOKEN_PREVIOUS]
|
||||
let matched = false
|
||||
for (const token of tokens) {
|
||||
if (token && /^[A-Za-z0-9_-]{43,128}$/.test(token)) {
|
||||
matched = timingSafeEqual(candidate, digest(token)) || matched
|
||||
}
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
export class StoreAccountPasswordRevealService {
|
||||
constructor(
|
||||
private readonly repository: RevealRepository = new StoreAccountPasswordRevealRepository(),
|
||||
private readonly decrypt: (value: string) => string = decryptStoreAccountPassword,
|
||||
) {}
|
||||
|
||||
async reveal(input: {
|
||||
accountId: unknown; body: unknown; authorization: string | null
|
||||
secure: boolean; post: boolean; requestId: string
|
||||
}): Promise<RevealResult> {
|
||||
const account = accountIdSchema.safeParse(input.accountId)
|
||||
const body = revealInputSchema.safeParse(input.body)
|
||||
const authenticated = authenticate(input.authorization)
|
||||
const event: RevealAudit = {
|
||||
requestId: input.requestId,
|
||||
requesterId: body.success ? body.data.requesterId : null,
|
||||
serviceId: authenticated ? FINANCE_SERVICE_ID : null,
|
||||
accountId: account.success ? account.data : null,
|
||||
purpose: body.success ? body.data.purpose : 'unknown', status: 403,
|
||||
}
|
||||
try {
|
||||
return await this.repository.transaction(async tx => {
|
||||
let result: RevealResult = { status: 403 }
|
||||
if (authenticated && await tx.limited(event)) {
|
||||
result = { status: 429 }
|
||||
} else if (authenticated && input.secure && input.post && account.success && body.success) {
|
||||
const encrypted = await tx.findPassword(account.data)
|
||||
if (!encrypted) result = { status: 404 }
|
||||
else {
|
||||
try {
|
||||
// Audit commits before a plaintext response can leave this service.
|
||||
const password = this.decrypt(encrypted)
|
||||
result = password ? { status: 200, password } : { status: 404 }
|
||||
} catch { result = { status: 503 } }
|
||||
}
|
||||
}
|
||||
await tx.audit({ ...event, status: result.status })
|
||||
return result
|
||||
})
|
||||
} catch {
|
||||
// Failure to persist audit/limit is fail-closed. Never emit the underlying error.
|
||||
return { status: 503 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,227 @@
|
||||
/** @jest-environment node */
|
||||
import { randomBytes, randomUUID } from 'node:crypto'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { Pool } from 'pg'
|
||||
import { encryptStoreAccountPassword } from '@/lib/store-account-crypto'
|
||||
import { StoreAccountPasswordRevealRepository } from '@/server/repositories/store-account-password-reveal.repository'
|
||||
import type { StoreAccountPasswordRevealService as ServiceType } from '@/server/services/store-account-password-reveal.service'
|
||||
import { POST } from '@/app/internal/store-accounts/[id]/password/reveal/route'
|
||||
|
||||
jest.mock('server-only', () => ({}))
|
||||
let mockService: ServiceType
|
||||
jest.mock('@/server/services/store-account-password-reveal.service', () => ({
|
||||
StoreAccountPasswordRevealService: jest.fn().mockImplementation(() => ({
|
||||
reveal: (...args: Parameters<ServiceType['reveal']>) => mockService.reveal(...args),
|
||||
})),
|
||||
}))
|
||||
|
||||
// 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', () => {
|
||||
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 token = randomBytes(32).toString('base64url')
|
||||
const edgeToken = randomBytes(32).toString('base64url')
|
||||
const startedAt = new Date().toISOString()
|
||||
let admin: Pool
|
||||
let db: Pool
|
||||
let initialRows: string
|
||||
let initialColumns: string[]
|
||||
let databaseCreated = false
|
||||
let roleCreated = false
|
||||
let sqlContainsPassword = false
|
||||
let consoleContainsPassword = false
|
||||
let logSpies: jest.SpyInstance[] = []
|
||||
|
||||
async function call(id = 'synthetic-1', actor = 'finance-user', authorization: string | null = `Bearer ${token}`) {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Forwarded-Proto': 'https', 'X-Store-Reveal-Edge-Token': edgeToken,
|
||||
}
|
||||
if (authorization) headers.Authorization = authorization
|
||||
return POST(new Request(`https://example.test/internal/store-accounts/${id}/password/reveal`, {
|
||||
method: 'POST', headers, body: JSON.stringify({ requesterId: actor, purpose: 'view' }),
|
||||
}), { params: Promise.resolve({ id }) })
|
||||
}
|
||||
|
||||
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 })
|
||||
await admin.query(`CREATE DATABASE "${dbName}"`)
|
||||
databaseCreated = true
|
||||
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,
|
||||
})
|
||||
db.on('connect', client => {
|
||||
const original = client.query.bind(client)
|
||||
// Observe SQL sent to PostgreSQL in memory, output a boolean only.
|
||||
client.query = ((...args: unknown[]) => {
|
||||
sqlContainsPassword ||= JSON.stringify(args).includes(password)
|
||||
return (original as (...args: unknown[]) => unknown)(...args)
|
||||
}) as typeof client.query
|
||||
})
|
||||
logSpies = ['log', 'warn', 'error', 'info', 'debug'].map(method =>
|
||||
jest.spyOn(console, method as 'log').mockImplementation((...args: unknown[]) => {
|
||||
consoleContainsPassword ||= args.some(arg => String(arg).includes(password))
|
||||
}))
|
||||
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);
|
||||
CREATE SCHEMA external_store_accounts;
|
||||
CREATE VIEW external_store_accounts.accounts_v1 AS
|
||||
SELECT id, "storeName" FROM public."StoreAccount" WHERE visible;
|
||||
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"
|
||||
SELECT 'synthetic-' || n, 'Synthetic store ' || n,
|
||||
CASE WHEN n <= 957 THEN $1 ELSE '' END, n <= 958 FROM generate_series(1,960) n`,
|
||||
[encryptStoreAccountPassword(password)])
|
||||
initialRows = JSON.stringify((await db.query('SELECT * 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'))
|
||||
const { StoreAccountPasswordRevealService } = jest.requireActual('@/server/services/store-account-password-reveal.service')
|
||||
mockService = new StoreAccountPasswordRevealService(new StoreAccountPasswordRevealRepository(() => db))
|
||||
}, 20000)
|
||||
|
||||
beforeEach(async () => { await db.query('TRUNCATE public."StoreAccountPasswordAccessAudit"') })
|
||||
|
||||
afterAll(async () => {
|
||||
logSpies.forEach(spy => spy.mockRestore())
|
||||
delete process.env.STORE_ACCOUNT_ENCRYPTION_KEY
|
||||
delete process.env.STORE_ACCOUNT_FINANCE_TOKEN
|
||||
delete process.env.STORE_ACCOUNT_REVEAL_EDGE_TOKEN
|
||||
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}"`)
|
||||
await admin.end()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves every original field and filter with 958 unique rows and a final boolean (957/1/0)', async () => {
|
||||
const { rows: [stats] } = await db.query(`SELECT count(*)::int AS total, count(DISTINCT id)::int AS unique,
|
||||
count(*) FILTER (WHERE "passwordConfigured" IS TRUE)::int AS yes,
|
||||
count(*) FILTER (WHERE "passwordConfigured" IS FALSE)::int AS no,
|
||||
count(*) FILTER (WHERE "passwordConfigured" IS NULL)::int AS unknown
|
||||
FROM external_store_accounts.accounts_v1`)
|
||||
expect(stats).toEqual({ total: 958, unique: 958, yes: 957, no: 1, unknown: 0 })
|
||||
const { rows: columns } = await db.query(`SELECT column_name, data_type FROM information_schema.columns
|
||||
WHERE table_schema='external_store_accounts' AND table_name='accounts_v1' ORDER BY ordinal_position`)
|
||||
expect(columns.map(c => c.column_name)).toEqual([...initialColumns, 'passwordConfigured'])
|
||||
expect(columns.at(-1)?.data_type).toBe('boolean')
|
||||
expect(JSON.stringify((await db.query('SELECT id, "storeName" FROM external_store_accounts.accounts_v1 ORDER BY id')).rows) === initialRows).toBe(true)
|
||||
})
|
||||
|
||||
it('returns correct synthetic plaintext through the HTTP handler only after audit commits', async () => {
|
||||
const response = await call()
|
||||
const body = await response.json()
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('cache-control')).toBe('no-store')
|
||||
expect(body.password === password).toBe(true)
|
||||
const { rows } = await db.query('SELECT * FROM public."StoreAccountPasswordAccessAudit" WHERE "requestId"=$1', [body.requestId])
|
||||
expect(rows.length).toBe(1)
|
||||
expect(rows[0].status).toBe(200)
|
||||
expect(rows[0].requesterId).toBe('finance-user')
|
||||
expect(rows[0].serviceId).toBe('finance-store-accounts')
|
||||
expect(rows[0].accountId).toBe('synthetic-1')
|
||||
expect(rows[0].purpose).toBe('view')
|
||||
expect(rows[0].createdAt instanceof Date).toBe(true)
|
||||
expect(JSON.stringify(rows).includes(password)).toBe(false)
|
||||
})
|
||||
|
||||
it('persists 403 and 404 attempts and uses no-store on both', async () => {
|
||||
const unauthorized = await call('synthetic-1', 'u', null)
|
||||
const missing = await call('does-not-exist')
|
||||
const empty = await call('synthetic-958')
|
||||
expect([unauthorized.status, missing.status, empty.status]).toEqual([403, 404, 404])
|
||||
expect([unauthorized, missing, empty].every(r => r.headers.get('cache-control') === 'no-store')).toBe(true)
|
||||
const { rows } = await db.query('SELECT status FROM public."StoreAccountPasswordAccessAudit" ORDER BY "createdAt"')
|
||||
expect(rows.map(r => r.status)).toEqual([403, 404, 404])
|
||||
})
|
||||
|
||||
it('enforces account limits atomically across concurrent requests and service instances', async () => {
|
||||
const responses = await Promise.all(Array.from({ length: 8 }, (_, n) => call('synthetic-1', `actor-${n}`)))
|
||||
expect(responses.filter(r => r.status === 200).length).toBe(5)
|
||||
expect(responses.filter(r => r.status === 429).length).toBe(3)
|
||||
expect(responses.every(r => r.headers.get('cache-control') === 'no-store')).toBe(true)
|
||||
const { StoreAccountPasswordRevealService } = jest.requireActual('@/server/services/store-account-password-reveal.service')
|
||||
const restarted = new StoreAccountPasswordRevealService(new StoreAccountPasswordRevealRepository(() => db))
|
||||
const result = await restarted.reveal({ accountId: 'synthetic-1', body: { requesterId: 'new-actor', purpose: 'copy' },
|
||||
authorization: `Bearer ${token}`, post: true, secure: true, requestId: randomUUID() })
|
||||
expect(result.status).toBe(429)
|
||||
})
|
||||
|
||||
it('enforces requester limits even when the caller changes accounts', async () => {
|
||||
const responses = []
|
||||
for (let n = 1; n <= 11; n++) responses.push(await call(`synthetic-${n}`))
|
||||
expect(responses.filter(r => r.status === 200).length).toBe(10)
|
||||
expect(responses[10].status).toBe(429)
|
||||
})
|
||||
|
||||
it.each([{ count: 30, age: '0 seconds' }, { count: 300, age: '2 hours' }])('enforces global service limits (%#)', async ({ count, age }) => {
|
||||
for (let n = 0; n < count; n++) {
|
||||
await db.query(`INSERT INTO public."StoreAccountPasswordAccessAudit"
|
||||
("requestId", "serviceId", purpose, status, "createdAt") VALUES ($1,'finance-store-accounts','view',404,clock_timestamp()-$2::interval)`,
|
||||
[randomUUID(), age])
|
||||
}
|
||||
expect((await call('synthetic-10', 'new-user')).status).toBe(429)
|
||||
})
|
||||
|
||||
it('returns 503 without plaintext when audit persistence fails', async () => {
|
||||
await db.query(`ALTER TABLE public."StoreAccountPasswordAccessAudit" ADD CONSTRAINT test_reject_success CHECK(status <> 200)`)
|
||||
try {
|
||||
const response = await call()
|
||||
expect(response.status).toBe(503)
|
||||
expect((await response.text()).includes(password)).toBe(false)
|
||||
} finally { await db.query('ALTER TABLE public."StoreAccountPasswordAccessAudit" DROP CONSTRAINT test_reject_success') }
|
||||
})
|
||||
|
||||
it('does not widen the ordinary reader ACL to the base table, password or audit', async () => {
|
||||
const client = await db.connect()
|
||||
try {
|
||||
await client.query(`SET ROLE "${readerName}"`)
|
||||
expect((await client.query('SELECT count(*)::int AS total FROM external_store_accounts.accounts_v1')).rows[0].total).toBe(958)
|
||||
for (const sql of [
|
||||
'SELECT * FROM public."StoreAccount" LIMIT 0',
|
||||
'SELECT "passwordCiphertext" FROM public."StoreAccount" LIMIT 0',
|
||||
'SELECT * FROM public."StoreAccountPasswordAccessAudit" LIMIT 0',
|
||||
]) {
|
||||
let denied = false
|
||||
try { await client.query(sql) } catch (error) { denied = (error as { code: string }).code === '42501' }
|
||||
expect(denied).toBe(true)
|
||||
}
|
||||
} finally { await client.query('RESET ROLE'); client.release() }
|
||||
})
|
||||
|
||||
it('contains no synthetic plaintext in application, audit or actual PostgreSQL query logs', async () => {
|
||||
await call()
|
||||
expect(sqlContainsPassword).toBe(false)
|
||||
expect(consoleContainsPassword).toBe(false)
|
||||
expect(JSON.stringify((await db.query('SELECT * FROM public."StoreAccountPasswordAccessAudit"')).rows).includes(password)).toBe(false)
|
||||
// Optional in CI; required for local acceptance when configured. Read output
|
||||
// only into memory and assert booleans so logs and secrets cannot enter reports.
|
||||
if (process.env.STORE_REVEAL_TEST_POSTGRES_CONTAINER) {
|
||||
const logs = spawnSync('docker', ['logs', '--since', startedAt, process.env.STORE_REVEAL_TEST_POSTGRES_CONTAINER], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 })
|
||||
const output = String(logs.stdout) + String(logs.stderr)
|
||||
expect(logs.status === 0).toBe(true)
|
||||
expect(output.includes('StoreAccountPasswordAccessAudit')).toBe(true)
|
||||
expect(output.includes(password)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user