申请 ID 唯一,查询仍然必须带租户

tenant-one 的采购员猜到 tenant-two 的申请 ID。若 DAO 用 SELECT ... WHERE id = ? 加载对象、业务检查后才过滤,未授权信息可能已进入响应、日志或后续订单写入。当前 JdbcRequestStore.find 的请求表读取已使用 id + tenant_id,但 LabRequestsResource 中 tenant_id 源自客户端可伪造的 X-Lab-Tenant;SQL 带租户条件不等于已获得可信租户。当前实验接口不能对公网开放。本章规定身份、组织关联、SQL 与异步消息的全链约束;相关真实跨租户安全验收均 NOT_RUN。

本篇源码、迁移、脚本与测试可从固定版本完整归档取得;版本 1b08ada,SHA-256 见源码清单。

在用户认证成功后,服务端以稳定 principal ID 查询可访问的租户集合。单租户账户由受信映射选定;多租户账户可提交租户选择值,但服务端必须验证集合成员资格,并为每个操作创建不可变的 AuthorizedTenant(userId, tenantId, permissions) 值。上下文不存在、账户禁用或关联被撤销都默认拒绝。不可从 X-Lab-Tenant、URL、JSON 体或跨请求复用的线程局部变量直接生成此值。租户与“本人创建的申请”仍是两个约束:即使同租户,非所有者也不自动可以提交草稿。

数据模型和访问入口

1
2
3
4
5
6
7
ALTER TABLE purchase_request ADD CONSTRAINT request_tenant_id_unique UNIQUE (tenant_id, id);
ALTER TABLE purchase_order ADD CONSTRAINT order_request_same_tenant
FOREIGN KEY (tenant_id, request_id) REFERENCES purchase_request (tenant_id, id);

SELECT id, status, version, total
FROM purchase_request
WHERE tenant_id = ? AND id = ?;

查询单个对象、列表、审批条件 UPDATE、订单写入、附件元数据和导出任务都带同一个可信租户字段。purchase_line 当前仅存 request_id;读取时先由 purchase_request 的受约束查询确定父对象,再取明细,列表连接也须在父表上先限租户。对于子表,复合外键可防止错误租户订单引用其他租户申请;但数据库约束不能替代读权限。purchase_order 的唯一 request_id 决定每个申请最多一单;新增复合外键只保证归属一致,不会授予查询权限。复合唯一约束在数据已有重复/不一致时会失败,迁移必须先检查并解决历史行,而不是关闭约束。

目标 HTTP 入口是 GET /api/requests/{id};从受管认证主体经可信组织映射得到 AuthorizedTenant 后,才调用 RequestStore.find(id, context.tenantId())。缺失身份/组织关联的请求不进入 DAO,跨租户查询结果按不可见处理,不给攻击者区分“存在于他人租户”与“完全不存在”的线索。当前工程没有该 GET 路由,ProcurementUseCases 仍接收 String tenantId,需要逐层改造才可接入。以下是目标服务边界的值类型,并非一个能直接运行的 REST 接口:

1
2
3
4
5
6
7
8
9
10
package blog.javaee.application;

public record AuthorizedTenant(String userId, String tenantId) {
public AuthorizedTenant {
if (userId == null || userId.isBlank() || tenantId == null || tenantId.isBlank()) {
throw new IllegalArgumentException("Verified identity and tenant required");
}
}
}

类型不等于权限证明:record 可以由任意同 JVM 调用方构造;还需把入口封装在受管理服务里,仅由可信身份解析器生成并重新验证组织成员资格。审批决策要同时经过第 25 篇的角色、额度与对象授权。异步通知没有原请求的 Servlet principal:在同一事务持久化任务的 tenant_id、申请 ID、操作者 ID 与业务事件标识;消费者按任务载荷重新查可信记录并限制租户,不从线程局部变量“继承”身份,不把用户 Cookie 或令牌写入消息。权限在任务执行时重新检查还是按业务事件时权限快照执行,必须依据通知业务语义固定,不可偷偷沿用旧线程的安全上下文。

验收矩阵及证据空缺

隔离库里建立合成 A/B 两个租户,各有一份申请、明细与订单。A 的受认证主体查询 A 的单及列表是正常路径;A 用 B 的 ID 访问详情、审批、列表筛选、导出是失败路径,B 的任何数据都不应出现在 A 的响应,数据库状态不应改变。移除 A 的组织关联、缺省租户、创建任务时没有租户信息,也应默认拒绝。分别核对 HTTP 状态、行集合、审计与导出内容;没有响应内容泄漏不等于数据库更新也正确。用两条并发请求交错访问 A/B,还要检查线程/连接复用不会串上下文。

这些命令尚不能用于安全验收:现有 /api/lab/requests/{id}/approve 没有可信主体;rg -n 'tenant_id|X-Lab-Tenant' examples/javaee-enterprise 只证明字符串出现,不证明身份与所有 DAO 都受约束。正常/跨租户/缺失上下文/异步恢复测试:NOT_RUN(缺身份、受信映射、正式读/导出入口与任务消费者)。若将来执行,必须使用受信账户登录、部署隔离数据库,保留脱敏 HTTP 与最终 SQL 行,不要把自填 header 当成 A/B 身份。

可信上下文怎样产生,又怎样失效

真实认证主体进入服务端后,目录查询返回它当前获准操作的租户集合。单租户用户可直接选唯一租户;多租户用户在界面选择租户时,提交的值只是候选项,服务端仍须用主体 ID 与受信成员表核对。这个动作发生在进入用例之前,查询失败或查不到成员资格时默认拒绝,不能因为数据库暂时不可用就回退到 X-Lab-Tenant。已撤销成员资格的旧会话是否继续有效,必须由组织映射缓存的失效策略决定;高风险的审批应在执行时复核而不是只依赖登录时保存的租户列表。角色获批与租户获批分别表示“能做这类动作”和“能在此范围做动作”,没有任何一项可以替代另一项。

AuthorizedTenant 只是一种防止 API 随手传裸字符串的类型约定。任何能构造这个 record 的调用者都可以填入另一个租户,因而工厂必须只接受容器认证后的主体和目录查询出的成员行;用例层不开放可由 JSON 自动绑定的该类型参数。失败边界也应可观察:无主体,拒绝;主体存在但没有成员行,拒绝;主体拥有 A 与 B,而当前请求选择 C,拒绝;主体选择 A 并对 A 的申请操作,则进入对象权限检查。若将来增加后台任务,那里没有请求的 SecurityContext,不能拿一个空主体默默指定“系统管理员”,须为任务身份和权限语义单独立合同。

读路径和写路径都要有相同的租户前提

现有 JdbcRequestStore.find 的父表查询写成 WHERE id=? AND tenant_id=?,两项都使用 PreparedStatement 参数;拿到该父申请的 ID 后再按 request_id 查子表。purchase_request.id 是全局主键,因此在当前表结构下,已通过父表过滤且子表外键指向该主键的读取链有明确归属。但新增列表/导出时不能直接从 purchase_line 扫出全部明细再在 Java 中过滤父对象,数据库结果和日志在此之前已经越界。列表需要从带租户条件的父表开始 JOIN,排序、分页和 COUNT(*) 查询同样加租户谓词;审批 UPDATE 与订单读取要在写入或返回之前核对同一可信租户,而不是只在 GET 详情里加条件。

在隔离库中,仅凭现有 schema 可做一个SQL 行范围对照,仍不能冒称认证测试。先用独立测试租户插入 A、B 各一条草稿申请,记录 A/B 的真实 id;从 psql -X -h 127.0.0.1 -U javaee_lab -d javaee_lab 运行:

1
2
3
4
5
6
7
INSERT INTO purchase_request(tenant_id,status,total)
VALUES ('tenant-a-isolated','DRAFT',3.50) RETURNING id;
INSERT INTO purchase_request(tenant_id,status,total)
VALUES ('tenant-b-isolated','DRAFT',3.50) RETURNING id;
\set b_id 123
SELECT id FROM purchase_request WHERE tenant_id='tenant-a-isolated' AND id=:b_id;
SELECT id FROM purchase_request WHERE id=:b_id;

把 123 换成刚插入的 B 的 id。前一查询预期零行,后一查询可以读到 B 行,这表明租户谓词影响 SQL 可见范围,不表明 HTTP 身份已经可信。这组手工测试在本文没有执行,NOT_RUN;真正的跨租户越权演练必须借第 24 篇受管身份入口,让 A 的主体发起 B 的 ID 请求,并观察 HTTP 内容、数据变更与日志。复制 SQL 并在实验环境直接以拥有全部行访问权的数据库账号执行,与应用权限机制属于两个不同层级。

复合外键防错引用,但不会替应用筛选结果

拟议复合外键需要被引用的 (tenant_id,id) 是唯一或主键,所以迁移先建立 request_tenant_id_unique,再让订单 (tenant_id,request_id) 指向该组合。这样即使程序拼错 SQL,把 A 租户的订单指向 B 的申请,数据库也应拒绝。但 purchase_order(request_id) 原来的单列唯一约束仍有独立作用:防同一申请多次下单;增加复合外键不能替换它。历史数据中如已存在租户/申请不匹配,添加外键会失败,必须先在隔离副本扫描和修复,不能短暂禁用约束后宣称完成隔离。

由于申请 ID 本身是全局主键,新增复合唯一在这个版本看似冗余,但它为外键定义了数据库可以验证的成对引用。是否所有子表都应复制 tenant_id 则取决于访问模式与迁移成本;没有租户列的明细表必须经受限父表关联查询。若附件元数据新增 tenant_id,亦要考虑与申请父行建立复合引用,防止元数据声称 A 而文件属于 B。物理约束解决“关联一致性”,认证身份和 SQL 谓词解决“谁可查看”,业务额度解决“谁可批准”;不要用一项测试给三项全部签字。

异步任务不能从请求线程偷取租户

订单创建提交时把可信租户、申请 ID、事件 ID 和必要的操作者 ID 与任务一同持久化,发布和消费可以发生在不同服务器、不同线程乃至次日。接收方按事件字段查询所属订单,再从受信业务数据核对归属;不能复制请求的 Cookie、JWT 或 Java ThreadLocal 作为异步身份。若任务投递时组织成员资格已经撤销,应按明确业务策略决定通知原采购员还是当前负责组织,而不是默认继续借用历史 principal 的权限。事件不可变身份、收件人查询时点及去重账本是三件事,第 23、30–31 篇各有不同的恢复边界。

最易漏掉的是异常分支和无对象分支:如果 A 查询 B 的 ID 得到“不存在”,错误日志不能附带 B 的供应商名称;如果从 A 的列表导出筛选 B 的 id,导出空集且不产生 B 的附件临时文件;如果任务无可信租户字段,消费者应拒绝并报警而不是默认为“上次处理的租户”。正式授权与导出服务仍不存在,所以这些是待实现的失败断言而不是某次本地 curl 的实测结果。

隔离失败可以是合法 SQL 得到错误结果

并非所有跨租户访问都表现为 SQL 注入或约束异常。SELECT ... WHERE id=? 可能是完全合法、使用 PreparedStatement 且执行很快的查询,只是没有把受信租户带进去;若请求持有猜中的主键,就会安静地取回别人的单。SELECT ... WHERE id=? AND tenant_id=? 虽然看起来正确,但若第二个参数来自 X-Lab-Tenant,攻击者仍可把它填成 B。参数绑定防的是语法被改写,租户解析防的是授权范围被伪造,两个问题各有独立证据。失败测试必须同时覆盖“SQL 忘了条件”和“SQL 有条件但租户不可信”,仅观察防注入探针没有意义。

分页查询还可能暴露侧信道。列表内容带 A 的租户条件,若 COUNT(*) 使用全库计数,A 仍可能从总记录数推断其他租户的活动;若搜索建议或导出文件名用全库供应商数据,正文虽过滤,辅助数据照样泄漏。需对列表、计数、排序、聚合、下载及错误响应逐条核对同一授权上下文。缓存键至少包含可信租户及可影响结果的权限范围;仅用 requestId 缓存列表或详情时,后续 B 的请求可能命中 A 的结果,SQL 再严密也挡不住缓存层的错误复用。现有累计工程没有该缓存或正式导出路由,这里是新增功能的设计检查,不是已观察的漏洞。

复合外键迁移也有失败路径:在加约束前先以租户不一致的历史订单为输入执行关联检查,确认不一致记录数为零;若不为零,应隔离修复并留原始审计,不可简单更改订单租户字段让它“通过”,因为这会改变业务归属。建约束成功后用隔离样本尝试把 A 的 purchase_order 指向 B 的申请,预期外键拒绝并回滚;再用受管身份的 A 请求 B 对象,预期查询不可见。前一项证明数据库关联一致性,后一项才检查应用权限,两者都没有原始执行记录,仍 NOT_RUN。

跨租户准备数据、SQL 范围对照和身份验收的区别见租户隔离实验卡;这些结果仍需后续受控运行。

两道有解的练习

练习一: WHERE id=? AND tenant_id=? 在 A 请求 B 对象时返回 0 行;若租户值直接来自 X-Lab-Tenant,是否达到隔离目标?

解: 没有。攻击者可把 header 改成 B,查询即有机会命中。修复顺序是认证主体、受信组织关联验证、服务端选租户,再把该值传给所有查询及更新;对无映射主体默认拒绝。正常路径和 header 冒用路径均需实际复测。

练习二: 异步订单通知任务只保存 request_id,消费者从本地线程变量读当前租户,偶尔发送错部门。应保存什么,还需验证什么?

解: 与订单/outbox 同事务存储 tenant_id、申请 ID、事件 ID 和必要的操作者标识;消费者按这组业务字段加载带租户条件的申请,再执行收件人选择。连接、线程和消息重试都不继承 HTTP 上下文;还需验证重投幂等、关联撤销后的通知策略及数据库外键。该消费者在当前工程中尚不存在,故为设计题而非故障复现。

版本与依据

基线:Jakarta EE 11/JDK 21、PostgreSQL 16 与 JDBC 显式 SQL;事务资源仍使用 Java SE javax.sql.DataSource。对照 Jakarta Security 4.0、Jakarta EE Platform 11、PostgreSQL 16 外键;JPA 的对应约束与并发对照仅指向已存在的JPA 采购审批综合应用。以上 SQL 是拟议迁移,未经构建、部署或真实数据库验证。