diff --git a/furniture-3d-system/docs/integrations/finance-store-account-password-reveal.md b/furniture-3d-system/docs/integrations/finance-store-account-password-reveal.md new file mode 100644 index 00000000..2371e4e7 --- /dev/null +++ b/furniture-3d-system/docs/integrations/finance-store-account-password-reveal.md @@ -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": "", "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拓扑/网关配置及发布后真实验收。 diff --git a/furniture-3d-system/prisma/migrations/20260908120000_store_account_password_reveal/migration.sql b/furniture-3d-system/prisma/migrations/20260908120000_store_account_password_reveal/migration.sql new file mode 100644 index 00000000..dcdf674e --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260908120000_store_account_password_reveal/migration.sql @@ -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; diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index 39535777..fe1b9643 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -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 diff --git a/furniture-3d-system/scripts/sql/check-store-account-password-reveal.sql b/furniture-3d-system/scripts/sql/check-store-account-password-reveal.sql new file mode 100644 index 00000000..50b0d637 --- /dev/null +++ b/furniture-3d-system/scripts/sql/check-store-account-password-reveal.sql @@ -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; diff --git a/furniture-3d-system/src/app/internal/store-accounts/[id]/password/reveal/route.test.ts b/furniture-3d-system/src/app/internal/store-accounts/[id]/password/reveal/route.test.ts new file mode 100644 index 00000000..597a81b8 --- /dev/null +++ b/furniture-3d-system/src/app/internal/store-accounts/[id]/password/reveal/route.test.ts @@ -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() + }) +}) diff --git a/furniture-3d-system/src/app/internal/store-accounts/[id]/password/reveal/route.ts b/furniture-3d-system/src/app/internal/store-accounts/[id]/password/reveal/route.ts new file mode 100644 index 00000000..4835b803 --- /dev/null +++ b/furniture-3d-system/src/app/internal/store-accounts/[id]/password/reveal/route.ts @@ -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 { + 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 diff --git a/furniture-3d-system/src/proxy-store-account-reveal.test.ts b/furniture-3d-system/src/proxy-store-account-reveal.test.ts new file mode 100644 index 00000000..dc4ce35e --- /dev/null +++ b/furniture-3d-system/src/proxy-store-account-reveal.test.ts @@ -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') + }) +}) diff --git a/furniture-3d-system/src/proxy.ts b/furniture-3d-system/src/proxy.ts index 3ea1ab36..63e5abdb 100644 --- a/furniture-3d-system/src/proxy.ts +++ b/furniture-3d-system/src/proxy.ts @@ -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/')) diff --git a/furniture-3d-system/src/server/repositories/store-account-password-reveal.repository.ts b/furniture-3d-system/src/server/repositories/store-account-password-reveal.repository.ts new file mode 100644 index 00000000..8f54d054 --- /dev/null +++ b/furniture-3d-system/src/server/repositories/store-account-password-reveal.repository.ts @@ -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) + .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(run: (tx: RevealTransaction) => Promise): Promise { + 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) + } + } +} diff --git a/furniture-3d-system/src/server/services/store-account-password-reveal-transport.ts b/furniture-3d-system/src/server/services/store-account-password-reveal-transport.ts new file mode 100644 index 00000000..f4473c4c --- /dev/null +++ b/furniture-3d-system/src/server/services/store-account-password-reveal-transport.ts @@ -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)) +} diff --git a/furniture-3d-system/src/server/services/store-account-password-reveal.contract.ts b/furniture-3d-system/src/server/services/store-account-password-reveal.contract.ts new file mode 100644 index 00000000..28ee8baf --- /dev/null +++ b/furniture-3d-system/src/server/services/store-account-password-reveal.contract.ts @@ -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 +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 + findPassword(accountId: string): Promise + audit(event: RevealAudit): Promise +} +export interface RevealRepository { + transaction(run: (tx: RevealTransaction) => Promise): Promise +} diff --git a/furniture-3d-system/src/server/services/store-account-password-reveal.service.test.ts b/furniture-3d-system/src/server/services/store-account-password-reveal.service.test.ts new file mode 100644 index 00000000..58f6c8d0 --- /dev/null +++ b/furniture-3d-system/src/server/services/store-account-password-reveal.service.test.ts @@ -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 + 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) + }) +}) diff --git a/furniture-3d-system/src/server/services/store-account-password-reveal.service.ts b/furniture-3d-system/src/server/services/store-account-password-reveal.service.ts new file mode 100644 index 00000000..7a455e0b --- /dev/null +++ b/furniture-3d-system/src/server/services/store-account-password-reveal.service.ts @@ -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 { + 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 } + } + } +} diff --git a/furniture-3d-system/tests/integration/store-account-password-reveal.test.ts b/furniture-3d-system/tests/integration/store-account-password-reveal.test.ts new file mode 100644 index 00000000..3a50cf93 --- /dev/null +++ b/furniture-3d-system/tests/integration/store-account-password-reveal.test.ts @@ -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) => 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 = { + '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) + } + }) +})