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:
@@ -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);
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -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);
|
||||
}
|
||||
|
||||
+8
-3
@@ -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);
|
||||
|
||||
+56
@@ -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
-1
@@ -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,
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user