两家租赁公司都可能有编号为 1 的合同。公司甲的金额为 100,公司乙的金额为 200,合同编号相同并不表示同一业务对象。多租户系统需要把公司身份一直保留到查询、缓存、任务与幂等记录,任何一层只留下合同编号,都可能把另一家公司的结果交给当前请求。

本篇采用合成数据演示一个受限方案:H2 2.1.214 中每个租户一个 schema,每个 schema 配一个应用账号。管理员负责建表和授权,业务查询使用 APP_A、APP_B。实验验证数据库权限与应用上下文的组合,消息部分只运行消费函数和持久 Inbox,没有启动消息中间件;线上身份认证、密钥轮换和跨机器攻击测试均为 NOT_RUN。

租户身份从哪里来

租户标识可以出现在请求头、路径、消息和配置项中,但这些位置提供的是请求声明。服务必须先依据已认证主体的授权关系确定可访问公司,再比较声明。代码以固定映射 alice→A、bob→B 代替真实身份系统,输入为空或 alice 声明 B 都在建立数据库连接前失败。这个映射证明检查顺序,不能证明真实令牌不可伪造。

一种常见错误是把请求头直接转成连接池键。攻击者修改标识后,应用会用自己的数据库凭据代其访问另一个 schema;数据库看到的访问身份仍然合法,无法知道应用选错了公司。因此,数据库最小权限与应用授权需要同时成立。只给 SQL 加 tenant_id 条件,也无法补回被错误信任的身份来源。

实验中 schema 名没有从任意文本拼接而来。connection 只在固定身份映射匹配后使用 A 或 B。未来租户动态开户时,应把外部公司编号映射为内部资源登记项,登记项包含账号、连接资源和配置版本,不能允许用户输入数据库用户名、文件路径或可执行类名。登记的维护属于管理操作,应与普通租赁接口分离。

下图回答请求如何取得数据库访问能力。它是权限关系图,省略真实登录协议、凭据存储和连接池实现;箭头不代表已经验证的网络调用。

flowchart LR
  P[已认证主体] --> M[服务端租户授权映射]
  H[请求声明租户] --> C{与授权租户一致}
  M --> C
  C -->|A| UA[应用账号 APP_A]
  C -->|B| UB[应用账号 APP_B]
  UA --> SA[Schema A]
  UB --> SB[Schema B]
  C -->|不一致或缺失| X[拒绝且不建连接]

schema 隔离究竟提供了什么

建库阶段为两个租户分别建立 contracts 与 inbox。每个 contracts 表都含有主键 1,因此查询无需额外租户谓词仍能得到本 schema 的合同。授权仅授予所属 schema 的 SELECT、INSERT、UPDATE;应用账号不是 schema 所有者,也没有管理员权限。相同表结构不代表相同数据所有权。

此方案的收益是把遗漏租户过滤条件的一类错误交给数据库权限兜底。APP_A 显式查询 B.contracts、显式更新 B.contracts,都得到 SQLSTATE 90096。断言要求精确拒绝码,避免把表不存在或语法写错误算为权限隔离成功。后续新连接读回甲乙金额仍然各自正确,越权失败没有改变乙公司的记录。

独立 schema 仍共享一个数据库进程、存储设备及运维权限。它没有证明资源竞争受到限制,也没有证明管理员无法读取数据。大量租户会增加表数量、迁移次数和连接资源成本;每个租户独立数据库进一步分离运维边界,却增加备份恢复和版本治理工作。共享表则减少对象数量,但要求每次访问都正确携带租户范围,或另行采用数据库行级安全能力。本篇没有运行 PostgreSQL RLS,不能把 H2 的 schema 权限结论套到 RLS 配置上。

连接复用也必须有明确策略。实验把 APP_A 的现有连接切换到 B schema,再执行不带 schema 名的查询,仍然得到权限拒绝。这个反例验证更改搜索路径不能增加账号权限。它并没有运行第三方连接池,因此连接借还时的自动重置、事务状态清理和池耗尽均未覆盖。实际接入连接池后,需要让池按受信任租户登记分区,并测试错误归还连接的路径。

缓存、配置和后台任务也有所有权

数据库隔离无法保护应用内的 Map。反例先把甲合同写到键 1,再把乙合同写到同一个键;甲读取到乙金额,检查明确观察到污染。修复后的键是 A:1、B:1,两项同时存在。实验没有接入 Redis,支持的结论是键组成必须包含隔离维度,分布式缓存的过期、淘汰和复制行为仍需单独验证。

配置同样按租户限定。样例将 A:currency 设为 CNY、B:currency 设为 USD,验证取值不会互相覆盖;这只是配置隔离样例,不意味着代码完成了汇率转换或跨币种结算。生产系统还需定义配置生效时间,保证合同已经接受的币种、价格版本不会因为后台配置变化被悄悄改写。合同快照与当前配置属于不同时间点的事实。

后台任务不能依赖处理请求时留下的线程变量。任务应保存租户与业务身份,执行时重新校验工作主体的授权,再选择租户资源。consume 接收 actor、tenant 和消息键,通过同一个 connection 边界取得权限;alice 向 B 提交任务失败。样例用显式参数消除隐式线程上下文,尚未验证线程池传播库。

下图关注消费事务,而非消息传输可靠性。省略 broker、投递确认和认证协议,图中的持久边界只有 H2 本地事务。

sequenceDiagram
  participant W as 后台任务
  participant A as 授权与资源选择
  participant D as 租户Schema
  W->>A: actor, tenant, job-1
  A->>A: 验证租户归属
  A->>D: BEGIN
  A->>D: INSERT inbox(job-1)
  alt 首次消费
    A->>D: UPDATE contracts amount+1
    A->>D: COMMIT
  else 唯一键重复
    A->>D: ROLLBACK
  end

同一个幂等键可属于不同公司

甲乙都提交 job-1 时,应分别执行一次。若幂等表只以消息编号为全局主键,乙公司的合法工作可能被当成甲的重放。schema 分隔使两个 inbox 中的 job-1 彼此独立。甲再次提交 job-1 时,唯一约束阻止第二次插入,事务回滚,金额不再增加。最终甲为 101、乙为 201。

唯一键冲突不是任意异常的通用成功信号。consume 只把 SQLSTATE 23505 当成重复,其余 SQL 异常继续传播。该样例的消息只有固定动作,没有金额等可变载荷,因此没有覆盖同键不同载荷的比较;扩展消息字段后,应保存业务指纹并拒绝冲突,而不是照搬“重复就跳过”。这一点与多租户维度独立,都必须出现在幂等契约中。

实验完成后启动另一个 Java 进程,重新连接同一文件数据库读取 101、201。它证明提交后的隔离数据和幂等结果跨进程保留,没有用原连接或进程内缓存充当持久化证据。测试数据库位于临时目录,运行结束删除;原始 SQL 输出、权限错误码和断言则写入证据目录,便于复核这次执行。

运行与判定

审计记录应同时包含业务公司、合同编号与实际访问账号。只记录合同编号无法区分两条正常业务;只记录数据库账号又无法回到用户发起的意图。本实验的查询输出同时打印 user 与 schema,权限拒绝输出保留 SQL 与 SQLSTATE,因此能够检查“声称访问甲、实际使用乙账号”的实现错误。日志中的 lesson 密码只用于临时教学库,不能当成部署配置示例。

租户停用也是权限生命周期的一部分。停止接收新请求以后,仍可能存在已排队任务、缓存条目和未完成事务。直接删除租户登记会使恢复工作失去定位依据,立即复用旧编号又可能让旧任务进入新公司的资源。安全的产品流程需要区分暂停新业务、清空在途工作、保留审计以及最终销毁数据,且每一步都应有明确可逆性。本次没有运行开户、停用和数据销毁,不能把静态两个租户的通过结果推广为完整生命周期保证。

判定隔离是否成立时,还需看拒绝之后的状态。越权请求返回错误但先修改了数据库,仍然属于失败;缓存查错后在展示层遮住字段,也没有修复越界读取。正因如此,场景既检查数据库错误码,也在新的受限连接上检查金额和重复消费结果。授权失败与无副作用是两个必须同时满足的断言。

在仓库根目录执行下列命令。需要 JDK 21,H2 解析器固定校验 jar 的 SHA-256;已有 jar 可通过 H2_JAR 指定。

1
bash examples/enterprise-application-architecture/labs/20/run.sh

源码入口是 Lab20.java;connection 负责授权与账号选择,deniedSql 检查拒绝码,consume 负责 Inbox 与合同更新的同事务。证据位于 examples/enterprise-application-architecture/evidence/20/normal.stdout.txt,环境与源码摘要同目录保存。运行结果中的 same-id-independent、cross-read、cross-write、reused-connection-schema、cache-scoped、tenant-idempotency-and-no-side-effect 及 restart-durable 必须全部 PASS,进程退出码必须为零。

首次运行因默认工具缓存路径不可写而失败,修复是使用已有且散列匹配的 H2 jar,失败输出保留在 initial-cache-failure.stderr.txt。这个失败发生在数据库启动前,不是隔离缺陷,也没有被计入成功断言。读者若处于受限环境,应把工具缓存设在可写目录,再完整重跑。

本章决策是保留两层边界:应用根据主体选择租户,数据库账号限定可访问 schema。租户数量、连接预算或迁移成本改变后,需要重新评估存储策略。已经验证的数据库访问不能代替真实 HTTP 认证、消息运行时和生产负载,这些未运行项继续保留在验收清单中。

参考资料

实验下载:独立实验与原始证据包

后篇:企业应用架构21:插件、业务扩展点与产品变化