租户隔离与请求签名:权限、防重放和业务重试
账单调整请求包含两种标识:要改哪张账单,以及这次调整是哪一笔操作。前者用于查询与授权,后者用于识别业务重试。再加上调用方凭据、租户归属和签名,一次更新才能回答:谁在改谁的数据,报文是否完整,重复到达会不会多加一次金额。
这些判断发生在不同位置。身份由认证机制取得,数据归属由服务端查询,签名校验原始请求字节,数据库事务把操作结果和金额变化一起提交。
主体、租户与对象怎样进入授权判断
客户端标识只用于选择对象
路径 /bills/bill-a、JSON 中的 object、GraphQL 参数及批量导出列表,都可能携带对象标识。客户端可以尝试换一个值。UUID 增加猜测成本,却没有给对象增加访问规则。
服务端需要组合主体、对象、动作和上下文。例如财务成员可以读取本租户账单;只有可调整状态的账单允许修改;超额调整还需要审批。对象应在当前可访问集合中查找,或在查到后立即完成同等强度的授权。仅要求登录会留下 OWASP 所描述的对象级授权漏洞 BOLA。
| 遗漏 | 具体请求 | 应约束的位置 |
|---|---|---|
| 对象级越权 | 查询他人的 billId | 对象加载、关联查询及下载 |
| 功能级越权 | 普通账号调用管理员入口 | 请求规则及服务方法 |
| 属性级越权 | 更新夹带 tenantId、ownerId、approved | 专用写入 DTO、字段映射及业务规则 |
| 批量越权 | 列表混入外租户 ID | 每个对象,明确全失败或逐项结果 |
外部返回 404 可以减少对象存在性泄露,但列表数量、搜索建议和错误细节也要保持合理一致。内部审计可以记录具体拒绝原因。
租户来源、成员关系和授权时点
浏览器发送的 X-Tenant-Id 可以表示“希望切换到哪个租户”。服务端先验证该主体的成员关系,再建立上下文。直接把请求头写入查询条件,会让客户端选择权限范围。
租户上下文常来自服务端成员关系、受信令牌声明或合作方密钥登记。后者通过 keyId 查到租户和允许动作。对象自身的 tenantId 来自持久化数据;已加入租户的成员还可能受角色、所有者或对象属性限制。OWASP 授权指南要求每次请求执行授权,并采用默认拒绝策略。
成员被移除后,旧 JWT、授权缓存和已开始的任务可能仍携带旧信息。明确允许滞后多久,再选择短令牌、在线版本检查、失效通知或执行前重查。敏感导出在入队时校验一次,下载时仍应确认领取者和任务归属;长任务也需要可取消或暂停的策略。
数据隔离覆盖哪些位置
共享表的主键、唯一键和外键通常需要包含租户维度。明细若只按 bill_id 关联账单,遗漏租户条件的连接查询可能拼接两个租户的数据。缓存 key、幂等键、对象存储路径、搜索过滤和消息也需要保存归属。
主体 alice ── 成员关系 ── tenant-a
│
账单查询 (tenant-a, bill-a)
缓存项 tenant-a : bill : bill-a
操作记录 tenant-a : partner-a : operation-id
导出任务 tenant-a + 发起者 + 查询范围
下载 重新验证领取者与任务归属共享库共享表便于统一运营,但每条数据路径都要执行租户约束。独立 schema 或数据库减少部分误查询影响,同时增加迁移、连接数和运维成本。能访问所有数据库的超级账号,会削弱物理拆分所期待的保护。
PostgreSQL RLS 的作用与限制
行级安全策略把行过滤放到数据库。本例使用当前事务的 app.tenant:
ALTER TABLE sec15_replay.bill ENABLE ROW LEVEL SECURITY;
ALTER TABLE sec15_replay.bill FORCE ROW LEVEL SECURITY;
CREATE POLICY bill_tenant ON sec15_replay.bill
USING (tenant = current_setting('app.tenant', true))
WITH CHECK (tenant = current_setting('app.tenant', true));USING 控制已有行是否可见;WITH CHECK 控制新行或更新后的行。没有上下文时比较得不到 true,本例查不到行。启用 RLS 而没有适用策略,普通访问默认拒绝。超级用户和 BYPASSRLS 角色仍绕过 RLS,表所有者通常也绕过,FORCE 可约束普通所有者。例外见 PostgreSQL 行级安全文档。
connection.setAutoCommit(false);
try (var statement = connection.prepareStatement(
"SELECT set_config('app.tenant', ?, true)")) {
statement.setString(1, verifiedTenant);
statement.execute();
}
// 在同一个 Connection、同一个事务中查询和更新
connection.commit();第三个参数 true 表示事务局部设置,提交或回滚后恢复,适合连接池复用。set_config 的作用域应与应用事务一致:自动提交模式下单独设置,下一条语句已经失去上下文;会话级设置未清理,下一个借用者可能继承上一租户。
共享普通角色有能力设置 app.tenant。这套策略能兜住遗漏过滤的查询,无法防住已控制应用进程、可任意执行 SQL 的攻击者或管理员。应用授权、防 SQL 注入、最小数据库权限仍然必要。
签名、防重放与业务幂等怎样配合
先约定签哪些字节
HMAC 双方持有同一密钥。方法、目标地址、相关头字段和正文摘要按规则组合后生成 MAC;验证方重建同样的输入并比较。无密钥的 SHA-256 摘要只能检查内容变化,持有报文的人可以连同摘要一起重算。
协议需约定方法大小写、路径转义、查询参数重复与顺序、空白、多值头字段、正文编码和代理改写后的目标。签名只保护被选中的字段;没有覆盖金额、路径或操作 ID,就无法保护其替换。
生产互通优先采用 RFC 9421 HTTP Message Signatures及双方约定的 profile。正文通过内容摘要字段加入受签范围时,格式参考 RFC 9530。应用还要规定 keyId 查找范围、允许算法、时间和重放处理。
实验收窄为 POST /adjust、application/json 和服务端登记的 partner-a,签名输入固定为八行:
POST
/adjust
application/json
keyId
created
nonce
operationId
SHA-256(原始 body 字节) 的十六进制值它用于观察处理顺序,不实现 RFC 9421 语法,也不支持任意代理、多个目标主机或通用回调。生产接入多个服务时,应把目标服务绑定进标准 profile,防止合法签名被搬到另一处使用。
验签前控制成本,验签后解析业务
入口先限制请求大小和头字段格式,再检查 keyId 与时间窗。正文按实际字节计数,不只信任 Content-Length。固定长度 MAC 比较成功后,才解析 JSON、查对象和更新。
密钥由安全随机源生成,放在受限配置或秘密管理服务中。算法由服务端决定,未验证的 alg、keyId 不得指向任意文件、URL 或数据库记录。错误可以统一为 signature_rejected,内部统计再区分未知 key、过期和摘要不一致;日志不保存密钥和敏感正文。
HTTPS 继续保护传输。消息签名不提供加密,HMAC 验证方也能生成合法 MAC;需要参与方分别持有签名和验签能力时,可选择相应的非对称方案。
nonce 与 operationId 的用途和保留期
时间窗限制报文能被接受多久,窗口内仍可复制。nonce 应具有足够随机性,在验证通过后通过共享原子存储占用,键至少包含租户、密钥标识和 nonce。先查再插会让并发请求同时通过。
业务重试保留 operationId,生成新 nonce 和时间戳。服务端把 operationId 绑定到已验证的租户、调用方和请求摘要;完成后返回存储结果,同一标识更换输入则拒绝。
第一次发送 nonce=n1, operation=o1, amount=17
数据库提交 o1 结果与账单 amount=17、version=2 同时提交
响应丢失 客户端不知道结果
重新发送 nonce=n2, operation=o1, amount=17
查询原操作 返回 o1 已存结果,不再加金额
原报文重发 nonce=n1 → 重放拒绝
换金额沿用操作 operation=o1 → 内容冲突
另一次合法调整 operation=o2 → 新的授权和版本检查nonce 至少保留到签名最后可能被接受的时刻,并为清理调度、时钟误差留空间。业务记录的保留期由客户端重试、对账和重复入账风险决定,通常更长。删除过早会让旧操作再次执行。
结果和金额更新放进同一事务
实验将 nonce、operation、bill 放在 PostgreSQL 同一事务中。nonce 唯一键处理报文重复,operation 唯一键处理业务重复;金额更新同时带 tenant、id 和 version。保存结果后才提交。
INSERT ... ON CONFLICT DO NOTHING 使竞争同一操作的请求等待已有事务结束,再查询其已提交结果。PostgreSQL INSERT 文档说明唯一冲突处理。这里使用默认 READ COMMITTED;更换隔离级别需重新设计冲突与事务重试。
nonce 在 Redis、业务在数据库时存在崩溃窗口:先占 nonce 后宕机,业务可能没做;先做业务再记 nonce,业务可能重复。最终扣款、出库仍需在业务持久化层幂等。支付网关等外部副作用还需对方幂等键、Outbox 或状态机恢复,详见从写入到一致状态。
在 Linux 与 PostgreSQL 中执行正反实验
环境与初始化
下载完整 tenant-replay-lab 工程,解压后进入目录。宿主需要 Bash、Docker CLI、curl 7.76+、OpenSSL、unzip,以及专用开发 daemon 的使用权限。Java 17 编译,Maven 3.9.12,PostgreSQL 18.6。
应用使用 JDK HttpServer、pgJDBC 和 Jackson;Spring Boot 4.1.1 负责依赖管理及打包。HttpServer 通过本机映射端口实验,生产入口还需 TLS、慢速请求治理、连接和并发限额。
初始化会删除一次性 tenant_lab 数据库中的 sec15_replay schema。保持独立容器和 LAB_RESET_DATABASE 标识,不接入已有业务库。
set -euo pipefail
LAB_DIR="$(pwd -P)"
LAB_MAVEN_IMAGE=maven:3.9.12-eclipse-temurin-17
umask 077
mkdir -p .m2 secrets
openssl rand -hex 24 > secrets/admin
openssl rand -hex 24 > secrets/app
openssl rand -hex 32 > secrets/hmac
docker network create sec15-tenant-net
docker run -d --name sec15-tenant-db --network sec15-tenant-net \
-e POSTGRES_DB=tenant_lab -e POSTGRES_PASSWORD_FILE=/secrets/admin \
--mount "type=bind,src=$LAB_DIR/secrets,dst=/secrets,readonly" postgres:18.6
for attempt in $(seq 1 60); do
if docker exec sec15-tenant-db pg_isready -h 127.0.0.1 -U postgres -d tenant_lab; then break; fi
sleep 1
done
docker exec sec15-tenant-db pg_isready -h 127.0.0.1 -U postgres -d tenant_lab数据库端口不发布到宿主。官方镜像完成目录准备后降为 postgres 身份;Java 构建与运行使用宿主用户。名称冲突时先核对已有对象,不删除他人的数据库。
lab_java() {
docker run --rm --network sec15-tenant-net --user "$(id -u):$(id -g)" \
-e MAVEN_CONFIG=/tmp/.m2 \
-e 'LAB_DB_URL=jdbc:postgresql://sec15-tenant-db:5432/tenant_lab?connectTimeout=3&socketTimeout=5' \
-e LAB_RESET_DATABASE=local-only \
-e LAB_ADMIN_PASSWORD_FILE=/secrets/admin -e LAB_APP_PASSWORD_FILE=/secrets/app \
-e LAB_HMAC_KEY_FILE=/secrets/hmac \
--mount "type=bind,src=$LAB_DIR,dst=/work" \
--mount "type=bind,src=$LAB_DIR/.m2,dst=/m2" \
--mount "type=bind,src=$LAB_DIR/secrets,dst=/secrets,readonly" \
--workdir /work "$LAB_MAVEN_IMAGE" "$@"
}
lab_java mvn -B -ntp -Duser.home=/tmp -Dmaven.repo.local=/m2 clean verify预期 10 个测试、零失败和错误、零跳过。测试覆盖普通角色、RLS 提交/回滚、WITH CHECK、篡改与长度/时间限制、越租户对象、重放、版本冲突、八路并发,以及事务提交后关闭 HTTP 连接的响应丢失。Java 25 重跑:
LAB_MAVEN_IMAGE=maven:3.9.12-eclipse-temurin-25 \
lab_java mvn -B -ntp -Duser.home=/tmp -Dmaven.repo.local=/m2 clean verify连接失败先查数据库健康和网络,密码失败再核对挂载文件。测试失败时查看 Surefire 报告,不跳过测试生成构件。
启动应用并发起调整
先将测试留下的表恢复到初始状态,再启动服务:
lab_java java -jar target/tenant-replay-lab-1.0.0.jar init
docker run -d --name sec15-tenant-api --network sec15-tenant-net \
--user "$(id -u):$(id -g)" --read-only --tmpfs /tmp:rw,noexec,nosuid,size=32m \
-p 127.0.0.1:18532:18532 \
-e 'LAB_DB_URL=jdbc:postgresql://sec15-tenant-db:5432/tenant_lab?connectTimeout=3&socketTimeout=5' \
-e LAB_APP_PASSWORD_FILE=/secrets/app -e LAB_HMAC_KEY_FILE=/secrets/hmac \
--mount "type=bind,src=$LAB_DIR/target/tenant-replay-lab-1.0.0.jar,dst=/app.jar,readonly" \
--mount "type=bind,src=$LAB_DIR/secrets/app,dst=/secrets/app,readonly" \
--mount "type=bind,src=$LAB_DIR/secrets/hmac,dst=/secrets/hmac,readonly" \
eclipse-temurin:17.0.20_8-jdk java -Xmx128m -jar /app.jar
for attempt in $(seq 1 60); do
if curl -q --noproxy '*' --silent --show-error --fail-with-body --max-time 2 http://127.0.0.1:18532/health; then break; fi
sleep 1
done
curl -q --noproxy '*' --fail-with-body --max-time 5 http://127.0.0.1:18532/health || {
docker logs --tail 40 sec15-tenant-api
exit 1
}
lab_java java -jar target/tenant-replay-lab-1.0.0.jar sign requests/adjust.json op-demo > first.headers
curl -q --noproxy '*' --fail-with-body --max-time 8 \
-H @first.headers --data-binary @requests/adjust.json http://127.0.0.1:18532/adjusthealth 返回 ready 只表示 HTTP 已启动,调整请求才访问数据库。首次返回 amount=17、version=2,JSON 字段顺序无含义。应用角色无超级用户与 BYPASSRLS 权限,运行容器不挂载管理员密码。
curl 的 -q 放在第一项,--noproxy '*' 隔离环境代理。--data-binary 保留原字节。签名后格式化 JSON 或改变末尾换行,都会改变摘要。签名在生成后 60 秒内使用,超时应重新生成。
区分原报文重发与业务重试
重发原报文,预期 HTTP 409 且正文为 replay:
STATUS=$(curl -q --noproxy '*' --silent --show-error --max-time 8 \
-o replay.body -w '%{http_code}' -H @first.headers \
--data-binary @requests/adjust.json http://127.0.0.1:18532/adjust) || exit 1
test "$STATUS" = 409 && test "$(cat replay.body)" = replay || exit 1保持 op-demo,生成新 nonce,返回已保存的 amount=17、version=2:
lab_java java -jar target/tenant-replay-lab-1.0.0.jar sign requests/adjust.json op-demo > retry.headers
curl -q --noproxy '*' --fail-with-body --max-time 8 \
-H @retry.headers --data-binary @requests/adjust.json http://127.0.0.1:18532/adjustchanged.json 把金额改为 19。即使签名合法,也不能复用 op-demo 改写操作:
lab_java java -jar target/tenant-replay-lab-1.0.0.jar sign requests/changed.json op-demo > changed.headers
STATUS=$(curl -q --noproxy '*' --silent --show-error --max-time 8 \
-o changed.body -w '%{http_code}' -H @changed.headers \
--data-binary @requests/changed.json http://127.0.0.1:18532/adjust) || exit 1
test "$STATUS" = 409 && test "$(cat changed.body)" = operation_body_changed || exit 1foreign.json 的对象是 tenant-b 的 bill-b。换一个操作标识签名后仍不可访问:
lab_java java -jar target/tenant-replay-lab-1.0.0.jar sign requests/foreign.json op-foreign > foreign.headers
STATUS=$(curl -q --noproxy '*' --silent --show-error --max-time 8 \
-o foreign.body -w '%{http_code}' -H @foreign.headers \
--data-binary @requests/foreign.json http://127.0.0.1:18532/adjust) || exit 1
test "$STATUS" = 404 && test "$(cat foreign.body)" = not_found || exit 1partner-a 只能取得 tenant-a。应用查询条件和数据库策略都限制这次更新;缺失对象与外租户对象采用相同外部响应。
重试、轮换与异常处理
按拒绝阶段查原因
| 现象 | 优先检查 | 下一步 |
|---|---|---|
| 401 signature_rejected | keyId、时间偏差、字节和签名字段 | 受控环境比对摘要,不记录秘密正文 |
| 400 request_shape | 方法、路径、查询串、Content-Type | 修正约定 |
| 413 too_large | 实际读取字节数 | 调整上传方式或经容量评估的上限 |
| 409 replay | 是否重复 nonce | 新 nonce、原 operationId |
| 409 operation_body_changed | 原操作是否换输入 | 查询原操作,新业务用新标识 |
| 409 version_conflict | 对象是否已被更新 | 重新读取,由业务决定合并或重做 |
| 404 not_found | 当前可访问范围 | 服务端核对归属 |
| 503 或传输中断 | 事务是否已提交 | 新 nonce 查询或重试原 operationId |
数据库不可用时,实验拒绝继续,因为无法可靠占用 nonce 或保存结果。恢复后仍使用原 operationId 查明状态,避免另造一笔操作。
密钥轮换、清理和容量
keyId 映射密钥版本、合作方、租户和动作。轮换时先接受新版本,合作方切换后,按旧版本最晚接受时间退出旧密钥。确认泄漏则立即停用并核查受影响请求。
实验的 operation 键包含 keyId,假设业务重试使用同一密钥版本。跨轮换重试时,生产键应绑定稳定 partner 身份,把密钥版本单独记录;否则两个 keyId 下的同一操作可能分别执行。
nonce 清理只处理确定超出接受窗口的记录,operation 使用更长的保留期。清理由专用维护角色执行,应用无需 DELETE。针对租户/key 的验签量、冲突和数据库等待分别设限,避免大量合法形状请求耗尽计算或唯一索引资源。
清理实验资源
核对名称确属本节创建对象后执行。-v 会同时移除该数据库容器的匿名实验卷,全部实验账单、nonce 和操作结果随之删除;本节没有命名卷,宿主绑定的 secrets 目录保留:
docker rm -f -v sec15-tenant-api sec15-tenant-db
docker network rm sec15-tenant-net源码、Maven 缓存、target 与本机秘密文件保留。停止使用后清理 secrets 和请求头文件,不提交或复用实验密钥。
权威资料与规范地址
对象授权:OWASP API1 BOLA:https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/、Authorization Cheat Sheet:https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html。
数据库:PostgreSQL 18 Row Security Policies:https://www.postgresql.org/docs/18/ddl-rowsecurity.html、System Administration Functions:https://www.postgresql.org/docs/18/functions-admin.html、INSERT / ON CONFLICT:https://www.postgresql.org/docs/18/sql-insert.html。
消息签名与摘要:RFC 9421:https://www.rfc-editor.org/rfc/rfc9421、RFC 9530:https://www.rfc-editor.org/rfc/rfc9530。
