204 成功却没有审计行,算不算成功

一笔采购被批准,purchase_request.status 已变为 APPROVED,审批记录或审计事件却未写入。以后无法重建是谁、在什么权限和版本下做了决定。打印一行“approve succeeded”不能代替持久化审计;日志可丢、可延迟,也不能天然与数据库事务原子提交。现有累计工程的 ProcurementUseCases.approve 执行状态迁移,schema 还没有审批记录表或业务审计表。本章给出要增加的结构和故障策略;以下代码、正常/拒绝/回滚审计实验均 NOT_RUN。

审计记录至少要有受认证操作者稳定 ID、可信租户 ID、申请 ID、操作类型、决策结果、发生时间、请求关联 ID、决定时的申请版本及原因码;审批业务记录另存决定者及决定时额度(如需追溯)。时间由服务端生成并按 UTC/明确时区持久化。拒绝尝试可能涉及不存在的对象:记录受限的请求 ID 和原因码即可,不能为“确认该对象存在”回读其他租户明细。不要记录口令、Cookie、Bearer 令牌、供应商敏感原文或完整 HTTP body;还要规定查看审计的角色、保留期限、访问日志、修订及删除政策。

同一事务与独立尝试,是两类事件

成功决定与状态更新必须同事务:审批条件 UPDATE 命中 1 行,插入不可变 APPROVED 事件,提交后两行均可见;插审计失败应使审批一同回滚。版本竞争返回 0 行时,不得伪造 APPROVED 事件。拒绝尝试若希望即使业务回滚仍留痕,需要在独立事务写 DENIED/FAILED 安全事件;不能在会随业务回滚的事务里写完便说“拒绝审计已保存”。独立记录可能早于业务事务最终确认,因此必须区分 ATTEMPTED、COMMITTED、DENIED、FAILED,不能把“进入方法”写成“已批准”。外部集中审计系统也会面临不可用与重试,不能声称跨库写入天然原子。

1
2
3
4
5
6
7
8
9
10
11
CREATE TABLE approval_audit (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id text NOT NULL,
request_id bigint NOT NULL,
actor_id text NOT NULL,
request_version bigint NOT NULL,
decision text NOT NULL CHECK (decision IN ('APPROVED','REJECTED')),
occurred_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
correlation_id text NOT NULL,
FOREIGN KEY (tenant_id, request_id) REFERENCES purchase_request(tenant_id, id)
);

实际应用代码入口应在受管服务的公共方法,从当前受信 principal/关联查询得到操作者的组织与额度,执行第 25 篇授权,然后同一受管事务内调用条件 UPDATE 与 AuditDao.insertSuccess。不能把请求体里的 actor 作为方法参数直接放进审计事件。AuditDao 必须从同一个事务可加入的受管 DataSource 借连接,不自行 commit 或 setAutoCommit(false);若用两条连接,要验证它们确实加入当前事务且同一数据库资源可正确回滚。不能指望跨线程局部变量保留事务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package blog.javaee.application;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.security.enterprise.SecurityContext;
import jakarta.transaction.Transactional;

@ApplicationScoped
public class ApprovalService {
@Inject AuthorizedApprovalDao approvals;
@Inject AuditDao audit;
@Inject ActorDirectory actors;
@Inject SecurityContext security;

@Transactional
public void approve(long requestId, long version) {
if (security.getCallerPrincipal() == null) throw new SecurityException("Authentication required");
AuthenticatedActor actor = actors.fromPrincipal(security.getCallerPrincipal());
if (actor == null) throw new SecurityException("Authentication required");
if (approvals.approveIfAllowed(actor, requestId, version) != 1) {
throw new IllegalStateException("Not approvable or not visible");
}
audit.approved(actor.userId(), actor.tenantId(), requestId, version);
}
}

上述 AuthenticatedActor、ActorDirectory 和两个 DAO 均为待实现类型,片段不可单独编译或调用;默认 @Transactional 回滚规则须针对实际异常类别验证,这里故意用未经捕获的运行时异常使成功事件插入失败时整个事务失败。对业务拒绝事件可在受信入口结束事务之后,调用另一个经容器代理的服务方法并设置 @Transactional(Transactional.TxType.REQUIRES_NEW);同一类直接自调用未必经过事务拦截,必须用真实受管调用和独立连接实验验证。审计库不可用时,成功审批采取“拒绝并回滚”,拒绝尝试的记录失败则向安全运维发警报、拒绝高风险操作(具体回退策略待业务确定);不要吞掉审计异常继续返回审批成功。

要看三种结果而不是一条日志

后续在隔离库造一张已提交申请:正常审批后同时核对申请状态、版本和一条匹配主体/租户/版本的 APPROVED 审计;使审计 INSERT 故意失败,预期事务回滚、申请仍 SUBMITTED、无成功事件;用越权主体或旧版本调用,预期无状态改变,也无成功事件,若启用独立拒绝事件则单独检查原因码与持久性。模拟审计通道故障只能在隔离实例,不可破坏共享数据库;保存脱敏请求与最终 SQL 行。正常提交、审计插入失败、拒绝及回滚均 NOT_RUN:当前没有审计表与 ApprovalService,现有场景脚本只覆盖各自原有的业务断言。

成功事实与访问尝试为什么要分账

“审批员点了批准”与“采购申请最终批准成功”不是同一个事实。后者应在数据库提交之后才可对外宣称,并在业务事务里同时产生可追溯的成功事件;前者即使因为无权限、版本冲突或校验失败而回滚,仍可能需要安全团队调查。若把成功事件写在受管事务之外,数据库更新成功而审计服务网络失败,就出现未记录的业务决定;若把所有拒绝尝试也写进同一事务,业务回滚时拒绝记录会一起消失。按使用目的拆账:成功决策与申请状态同一 PostgreSQL 事务提交;需长期留存的拒绝尝试,在另一条明确的、独立提交的受管路径记录为 DENIED 或 ATTEMPTED。独立尝试记录无法证明审批曾提交,也不得挂名为 APPROVED。

审计中要保留的是“决策可重建所必需的事实”,而不是完整 HTTP 请求。主体应来自受信认证结果、租户来自当时验证的组织映射、申请版本是决定前的版本、决定类型与结果是枚举;若额度会变,需记录授予额度所依据的策略版本或可信授权记录标识。只存 actor_id 而无法追溯当时组织成员资格,日后很难判断审批当时是否有权;反过来记录完整证件信息、供应商报价或用户 Cookie 又会让审计系统本身变成高敏数据泄漏点。访问审计表的角色、保留周期、导出审批和事件不可静默修改的策略,都是设计的一部分,不靠一条 INSERT 自动获得。

审计行上的 FOREIGN KEY (tenant_id,request_id) 有一个实际的迁移前提:当前初始 schema 仅给申请 id 单列主键,没有 (tenant_id,id) 唯一键。必须先完成第 26 篇提出的复合唯一约束、核对历史订单和申请租户关系,再建本文的复合外键;直接在原库运行上面的 CREATE TABLE 会因为缺少匹配的唯一键失败。这里的事务示意是在迁移完成后的设计,不是现在执行过的一次建表。失败尝试可能携带不存在或不可见的申请 ID,也不应借复合外键把拒绝审计强制关联一个已存在对象;独立的安全事件表可以保存脱敏目标标识与原因码,而不让它引用他人租户的申请详情。

哪一次提交决定了对外状态

假设事务执行顺序为条件 UPDATE 返回申请 ID、写入审批成功事件、提交。第一步只证明这条 UPDATE 在当时事务里修改了一行;第二步成功也只是此事务内有审计行;只有最后提交成功,另一连接才能同时读到 APPROVED 状态和匹配的 approval_audit。如果审计 INSERT 违反约束、资源不可用或事务提交阶段失败,不能在 catch 块里写“审计失败但业务照常成功”;按本篇冻结的失败关闭策略,应让异常穿出受管入口并回滚申请状态。HTTP 204 只应在受管提交确认后写出,不能仅因为 DAO 方法返回就立即通知客户端。

如果业务选择不阻断审批,而要求成功后异步将审计送到独立系统,则要在本地事务里与状态一起写入持久 outbox 任务,再由发布器交付;它提供的是可恢复意图,不是瞬时的跨系统原子审计。任务发送可能重复,审计接收端要有事件 ID 去重;如果接收端永久不可用,还须明确积压告警及人工恢复。第 23 篇的 outbox 思路适用,但它当前也未落地。凭程序异步线程在提交后写审计不满足“业务提交必留审计”的合同,进程在两步间退出就会造成无法找回的缺口。

一组正常与故障 SQL 的明确终态

先在隔离库迁移完成后新建 tenant_id='audit-lab-unique' 的合成 SUBMITTED 申请,保存真实 ID,且使用专用测试 actor。下面的 SQL 是说明数据库原子性的计划操作,NOT_RUN,需要在 psql -X -h 127.0.0.1 -U javaee_lab -d javaee_lab 终端把 123 换成新建申请 ID;它不测试容器是否给审批员授予权限:

1
2
3
4
5
6
7
8
9
10
\set request_id 123
BEGIN;
UPDATE purchase_request SET status='APPROVED',version=version+1
WHERE tenant_id='audit-lab-unique' AND id=:request_id
AND status='SUBMITTED' AND version=0 RETURNING id;
INSERT INTO approval_audit(tenant_id,request_id,actor_id,request_version,decision,correlation_id)
VALUES ('audit-lab-unique',:request_id,'audit-lab-actor',0,'APPROVED','audit-normal-1');
COMMIT;
SELECT status,version FROM purchase_request WHERE id=:request_id;
SELECT decision,actor_id FROM approval_audit WHERE request_id=:request_id;

正常预期先更新 1 行,事务后状态为 APPROVED,1,对应成功事件恰好一条。失败对照另建第二张申请,将审计 actor_id 明确设为 NULL 触发 NOT NULL,在相同事务内执行 UPDATE 后的审计 INSERT,然后 ROLLBACK;独立连接预期仍 SUBMITTED,0 且没有成功事件。若 UPDATE 原本 0 行,不可继续 INSERT 虚假的成功审计;在 JDBC 用例中应先核对返回行数,失败时抛运行时异常。此 SQL 只在数据库层演示约束失败,容器自动回滚与对象授权还须等真正的 ApprovalService 部署后再测,不能用手工 psql 的回滚冒充 Jakarta Transactions 已通过。

拒绝尝试独立提交也会失败

当用户访问另一租户的申请,服务端可以只记录主体 ID、被请求的标识和“对象不可见”原因码;日志和审计事件不应把该申请的供应商名称或金额读出来,更不能把“同租户确实不存在”和“存在于他人租户”对外区分。若独立拒绝审计写入失败,可按风险分级阻断高风险审批并告警,但若认证机制已在调用业务资源前拒绝匿名用户,那个资源内部的审计代码不会执行。要覆盖入口前的拒绝,必须在实际执行拒绝的安全边界处记录受限事件,同时考虑认证日志和应用审计之间并没有自然的一次数据库事务。

有些失败无法精确归因:HTTP 客户端在服务器提交之后断开,数据库已有成功事件,却未收到 204;这时客户端重试应按业务操作身份回查,而不能因为未收到响应就再批准一次。审计系统也不能只根据一次 HTTP 500 宣告“没有成功决定”。验收需保存受管方法调用、事务完成、独立连接看到的申请与审计行,以及客户端可见响应四段证据。没有这组终态,就应标记未知或 NOT_RUN,不能为了补齐仪表盘而制造一条事后“成功”事件。

成功事件怎样避免同一决定被重试写两遍

审计表示意目前有自增主键和 correlation_id,但未定义一个业务审批决定的唯一键。若客户端在事务提交后断开,重试可能重新请求相同申请;有版本条件时第二次状态更新应为 0 行,不能在捕获冲突之后仍独立插一条 APPROVED。若业务允许重复发送同一操作意图,可在迁移里额外定义不可复用的操作 ID 与唯一约束,并在同事务中把成功事件绑定到该 ID;这需要先冻结“同一操作”的判定与保存期,不能直接把 HTTP request-id 当业务键,因为每次重试都可能换 request-id。当前 schema 与服务没有该键,新增唯一约束也是待实现设计,不可在现有审计表不存在时声称重复写入已被抑制。

运行时还要测“审计失败导致审批回滚”的真正路径:配置隔离事务的受管调用,在状态 UPDATE 一行后让成功事件 INSERT 触发可定位的数据库错误,保存 JDBC 异常链及容器事务结束结果,再从全新连接检查申请状态、版本和审计行。手工 psql ROLLBACK 只证明数据库可回滚,不证明 CDI 代理实际覆盖了两个 DAO;若两个 DAO 用了不同资源配置,可能出现审计回滚而状态已经提交。连接池配置、数据源名字、受管入口和 WAR 哈希都须与同一次运行记录对齐,目前此试验 NOT_RUN。

审计读取也要有自己的权限边界。能批准采购不等于能遍历全租户的审计事件,能读申请不等于可以修改决定者与时间戳。若业务要求防篡改,数据库访问控制、备份、专门的审计查询角色和保留政策需要逐项设计;单纯把表名叫 approval_audit 并未提供不可变存储。验收时还应验证业务 DAO 没有 UPDATE/DELETE 审计事件的正常入口,管理员例外操作有独立记录。敏感字段脱敏与保留期到期后的处置也需按组织合规要求冻结,本篇未实施这些部署策略。

成功、拒绝和审计写失败三类结果的记录字段见审计回滚实验卡;手工 SQL 与容器受管事务的证据必须分开。

两道有解的练习

练习一: audit.approved() 抛异常,应用 catch 后向客户端返回 204;申请状态已经提交。怎样修?

解: 审计成功事件和状态更新在同一受管数据库事务内,成功审计失败时不要吞异常,按事务回滚规则使审批也回滚;检查最终申请状态与审计行,不用 HTTP 500 代替数据库证据。若业务选择异步审计,必须有同事务 outbox 和可靠消费,不是随手另起线程写日志。

练习二: 进入审批方法即向同事务的 approval_audit 写 APPROVED,其后额度检查失败回滚。为什么既没有有效成功审计,也未留下拒绝证据?

解: APPROVED 在决策前写是虚假结论,回滚又令同事务写入消失。通过条件更新成功再写成功事件;拒绝尝试若需持久化,应在独立受管事务记录 DENIED 与受限原因,并注明它是尝试而非已提交的业务结果。此分支没有实测结果。

版本与依据

基线为 Jakarta EE 11/JDK 21、PostgreSQL 16、Open Liberty 26.0.0.5;受管事务语义见 Jakarta Transactions 2.0 与 Jakarta EE Platform 11,PostgreSQL 表结构参见 PostgreSQL 16 CREATE TABLE。JPA 的事务对照可读JPA 本地事务、JTA 与冲突,但这里采用 JDBC 与显式 SQL,未引入 ORM。规范语义与具体运行证据分开:本章没有实测提交或回滚。