fix(audit): 拒绝审计改 REQUIRES_NEW 独立事务提交(运行时实测抓到拒绝审计被业务回滚吞掉的系统性缺陷)

- 根因:DataScopeGuard.deny/denyAsNotFound/denyUser 与 AuthorizationAuditListener 的审计 insert 与业务同事务——拒绝抛异常必然回滚,审计(已执行 Updates:1)随之消失 → 生产全量 DATA_SCOPE_DENIED 零落库;H2 测试类 @Transactional(测试事务内可见未提交行)结构性假绿
- 运行时取证:outsider consent → deny insert DEBUG 日志 Updates:1 → psql count=0(回滚吞掉)
- 修复:新增 AuditDenyWriter(@Transactional REQUIRES_NEW)——deny/denyAsNotFound/denyUser 与 AuthorizationAuditListener 全部改走独立提交;成功路径审计保持与业务同事务(业务回滚审计一并消失,语义正确)
- 测试判别性加固:新增 denyAuditSurvivesBusinessRollback(@Transactional NOT_SUPPORTED 挂起测试事务跑真事务,旧实现必红);两处 operator deny 断言前 clearCache(REQUIRES_NEW 走独立会话不清外层 MyBatis 一级缓存——调试实证行已落库而过滤查询命中陈旧缓存)
- 负向注入:AuditDenyWriter 退化同事务 → 判别用例精准判红 → 还原复绿
- 验证:定向 46/46(授权 14+disabled 2+守卫 5+迁移 13+监听 1+矩阵 1+预约 3+路径 2+覆盖 5)
This commit is contained in:
WorkBuddy
2026-10-12 11:09:56 +08:00
parent ef61d51f61
commit 827c23db1d
7 changed files with 107 additions and 7 deletions
@@ -0,0 +1,33 @@
package com.danyang.match.platform.audit.service;
import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
/**
* 拒绝类审计的独立事务写入器(运行时实测修复)。
*
* <p>背景:拒绝审计(DATA_SCOPE_DENIED / AUTHORIZATION_DENIED)原先与业务方法同一事务——
* 拒绝时抛异常必然触发业务事务回滚,审计 insert(已执行 Updates:1)随之被回滚吞掉:
* 生产全量 deny 审计零落库。H2 集成测试因测试类 @Transactional(测试事务内可见未提交行)
* 结构性假绿。运行时实测(outsider consent → deny 行 0 条)抓到。
*
* <p>语义:REQUIRES_NEW 独立事务提交——外层业务事务回滚与否均不影响拒绝审计落库;
* 内层失败仍由调用方 catch 记日志后照常拒绝(审计失败不阻断拒绝响应)。
* 成功路径审计(SENSITIVE_ACCESS / 业务动作)保持与业务同事务(业务回滚审计一并消失,语义正确),
* 不走本写入器。
*/
@Component
@RequiredArgsConstructor
public class AuditDenyWriter {
private final AuditLogMapper auditLogMapper;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void insertIndependent(AuditLog auditLog) {
auditLogMapper.insert(auditLog);
}
}
@@ -2,6 +2,7 @@ package com.danyang.match.platform.shared.security;
import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.audit.service.AuditDenyWriter;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -21,6 +22,7 @@ import java.util.UUID;
public class AuthorizationAuditListener {
private final AuditLogMapper auditLogMapper;
private final AuditDenyWriter auditDenyWriter;
@EventListener
public void onAuthorizationDenied(AuthorizationDeniedEvent<?> event) {
@@ -46,7 +48,8 @@ public class AuthorizationAuditListener {
}
try {
auditLogMapper.insert(audit);
// 拒绝审计独立事务提交:@PreAuthorize 拒绝若发生在业务事务内,同事务写入会被回滚吞掉
auditDenyWriter.insertIndependent(audit);
} catch (RuntimeException exception) {
log.error("写入授权拒绝审计失败: operatorId={}", principal.id(), exception);
}
@@ -2,6 +2,7 @@ package com.danyang.match.platform.shared.security;
import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.audit.service.AuditDenyWriter;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import lombok.RequiredArgsConstructor;
@@ -22,6 +23,7 @@ import org.slf4j.MDC;
public class DataScopeGuard {
private final AuditLogMapper auditLogMapper;
private final AuditDenyWriter auditDenyWriter;
public void deny(String operatorId, String resourceType, String resourceId, String message) {
// ZC-MM-006 / T01 兼容性:缺省调用(不带 purposeCode/assignmentId)仍可写
@@ -48,7 +50,8 @@ public class DataScopeGuard {
audit.setRequestTraceId(MDC.get("requestId"));
audit.setCreatedAt(Instant.now());
try {
auditLogMapper.insert(audit);
// 拒绝审计必须独立事务提交:与业务同事务会被拒绝异常的回滚吞掉(运行时实测缺陷)
auditDenyWriter.insertIndependent(audit);
} catch (RuntimeException exception) {
log.error("写入数据范围拒绝审计失败: operatorId={}, resourceType={}, resourceId={}",
operatorId, resourceType, resourceId, exception);
@@ -81,7 +84,8 @@ public class DataScopeGuard {
audit.setRequestTraceId(MDC.get("requestId"));
audit.setCreatedAt(Instant.now());
try {
auditLogMapper.insert(audit);
// 拒绝审计必须独立事务提交:与业务同事务会被拒绝异常的回滚吞掉(运行时实测缺陷)
auditDenyWriter.insertIndependent(audit);
} catch (RuntimeException exception) {
log.error("写入数据范围拒绝审计失败: operatorId={}, resourceType={}, resourceId={}",
operatorId, resourceType, resourceId, exception);
@@ -105,7 +109,8 @@ public class DataScopeGuard {
audit.setRequestTraceId(MDC.get("requestId"));
audit.setCreatedAt(Instant.now());
try {
auditLogMapper.insert(audit);
// 拒绝审计必须独立事务提交:与业务同事务会被拒绝异常的回滚吞掉(运行时实测缺陷)
auditDenyWriter.insertIndependent(audit);
} catch (RuntimeException exception) {
log.error("写入用户主体数据范围拒绝审计失败: subjectUserId={}, resourceType={}, resourceId={}",
userId, resourceType, resourceId, exception);
@@ -67,6 +67,7 @@ class MatchmakerContactAuthorizationTest {
@Autowired private UserMapper userMapper;
@Autowired private AuditLogMapper auditLogMapper;
@Autowired private SensitiveAccessLogMapper sensitiveAccessLogMapper;
@Autowired private org.mybatis.spring.SqlSessionTemplate sqlSessionTemplate;
private String mmOwner; // 分配持有人(红娘,org X)
private String leaderA; // 组长,org X(team.assign 审批岗)
@@ -257,6 +258,59 @@ class MatchmakerContactAuthorizationTest {
}
}
// ==================== 运行时回归:拒绝审计独立事务(REQUIRES_NEW) ====================
/**
* 运行时实测缺陷回归(判别性用例):NOT_SUPPORTED 挂起测试事务,让 service 跑真事务——
* deny 抛异常触发业务回滚后,拒绝审计必须仍然落库(REQUIRES_NEW 独立提交)。
* 旧实现(同事务写入)在本用例必红:回滚吞掉审计。类级 @Transactional 用例对此结构性假绿
* (测试事务内可见未提交行),故本用例独立夹具+显式清理。
*/
@Test
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.NOT_SUPPORTED)
@DisplayName("B1 运行时回归: 业务事务回滚不得吞掉拒绝审计(REQUIRES_NEW 独立提交)")
void denyAuditSurvivesBusinessRollback() {
String opOwner = createOperator("独立事务红娘", ORG_X);
String opLeader = createOperator("独立事务组长", ORG_X);
String ownUser = createUser("13811112222");
String otherUser = createUser("13811113333");
MatchmakerServiceRecord svc = new MatchmakerServiceRecord();
svc.setUserId(ownUser);
svc.setServiceType(Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER);
svc.setDescription("独立事务验证服务单");
svc.setStatus(Constants.MATCHMAKER_STATUS_SUBMITTED);
svc.setAssignedOperatorId(opOwner);
serviceMapper.insert(svc);
MatchmakerAssignment assignment = assignmentService.create(opLeader, true,
ownUser, opOwner, svc.getId(), "独立事务前置分配", null);
MatchmakerContactAuthorization auth = contactAuthorizationService.request(
opOwner, ownUser, Constants.CONTACT_CHANNEL_PHONE, "电话沟通");
contactAuthorizationService.approve(auth.getId(), opLeader, P_LEADER);
try {
assertThrows(AccessDeniedException.class,
() -> contactAuthorizationService.consent(auth.getId(), otherUser));
AuditLog denyAudit = auditLogMapper.selectList(new LambdaQueryWrapper<AuditLog>()
.eq(AuditLog::getAction, "DATA_SCOPE_DENIED")
.eq(AuditLog::getSubjectUserId, otherUser)
.eq(AuditLog::getResourceId, auth.getId()))
.stream().findFirst().orElse(null);
assertNotNull(denyAudit, "业务回滚后拒绝审计必须独立事务落库(REQUIRES_NEW)");
} finally {
// 显式清理(本用例数据真实提交,不随测试事务回滚)
auditLogMapper.delete(new LambdaQueryWrapper<AuditLog>()
.eq(AuditLog::getResourceId, auth.getId()));
auditLogMapper.delete(new LambdaQueryWrapper<AuditLog>()
.eq(AuditLog::getSubjectUserId, otherUser));
authorizationMapper.deleteById(auth.getId());
assignmentMapper.deleteById(assignment.getId());
serviceMapper.deleteById(svc.getId());
operatorMapper.deleteById(opOwner);
operatorMapper.deleteById(opLeader);
userMapper.deleteById(ownUser);
userMapper.deleteById(otherUser);
}
}
// ==================== 负向×6 ====================
@Test
@@ -281,6 +335,7 @@ class MatchmakerContactAuthorizationTest {
assertThrows(AccessDeniedException.class,
() -> contactAuthorizationService.approve(auth.getId(), leaderA, Set.of("matchmaker.view")),
"非审批岗不得 approve");
sqlSessionTemplate.clearCache();
assertEquals(before + 1, deniedAudits(leaderA, auth.getId()));
}
@@ -293,6 +348,7 @@ class MatchmakerContactAuthorizationTest {
assertThrows(AccessDeniedException.class,
() -> contactAuthorizationService.approve(auth.getId(), leaderY, P_LEADER),
"跨组织组长(仅主审岗)不得审批");
sqlSessionTemplate.clearCache();
assertEquals(before + 1, deniedAudits(leaderY, auth.getId()),
"跨组织组长审批拒绝必须落 DATA_SCOPE_DENIED 审计");
}
@@ -2,6 +2,7 @@ package com.danyang.match.platform.security;
import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.audit.service.AuditDenyWriter;
import com.danyang.match.platform.shared.security.AuthorizationAuditListener;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import org.junit.jupiter.api.Test;
@@ -20,7 +21,7 @@ class AuthorizationAuditListenerTest {
@Test
void deniedOperatorAuthorizationIsWrittenToAuditLog() {
AuditLogMapper mapper = mock(AuditLogMapper.class);
AuthorizationAuditListener listener = new AuthorizationAuditListener(mapper);
AuthorizationAuditListener listener = new AuthorizationAuditListener(mapper, new AuditDenyWriter(mapper));
OperatorPrincipal principal = new OperatorPrincipal("operator-1", Set.of("content.view"));
var authentication = UsernamePasswordAuthenticationToken.authenticated(principal, null, java.util.List.of());
var event = new AuthorizationDeniedEvent<>(() -> authentication,
@@ -8,6 +8,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import com.danyang.match.platform.audit.service.AuditDenyWriter;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
@@ -76,7 +77,7 @@ class V11MethodSecurityMatrixTest {
@Bean
AuthorizationAuditListener authorizationAuditListener(AuditLogMapper mapper) {
return new AuthorizationAuditListener(mapper);
return new AuthorizationAuditListener(mapper, new AuditDenyWriter(mapper));
}
@Bean
@@ -8,6 +8,7 @@ import com.danyang.match.platform.service_appointment.mapper.ServiceAppointmentM
import com.danyang.match.platform.service_appointment.mapper.ServiceLocationMapper;
import com.danyang.match.platform.service_appointment.service.ServiceAppointmentService;
import com.danyang.match.platform.shared.security.DataScopeGuard;
import com.danyang.match.platform.audit.service.AuditDenyWriter;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -34,7 +35,7 @@ class ServiceAppointmentDataScopeTest {
@BeforeEach
void setUp() {
service = new ServiceAppointmentService(appointmentMapper, locationMapper,
notificationService, riskReferralService, new DataScopeGuard(auditLogMapper));
notificationService, riskReferralService, new DataScopeGuard(auditLogMapper, new AuditDenyWriter(auditLogMapper)));
}
@Test