From 827c23db1db8b1c0e28e3c5f07a4b81420ff4733 Mon Sep 17 00:00:00 2001 From: WorkBuddy Date: Mon, 12 Oct 2026 11:09:56 +0800 Subject: [PATCH] =?UTF-8?q?fix(audit):=20=E6=8B=92=E7=BB=9D=E5=AE=A1?= =?UTF-8?q?=E8=AE=A1=E6=94=B9=20REQUIRES=5FNEW=20=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E4=BA=8B=E5=8A=A1=E6=8F=90=E4=BA=A4=EF=BC=88=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=97=B6=E5=AE=9E=E6=B5=8B=E6=8A=93=E5=88=B0=E6=8B=92=E7=BB=9D?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E8=A2=AB=E4=B8=9A=E5=8A=A1=E5=9B=9E=E6=BB=9A?= =?UTF-8?q?=E5=90=9E=E6=8E=89=E7=9A=84=E7=B3=BB=E7=BB=9F=E6=80=A7=E7=BC=BA?= =?UTF-8?q?=E9=99=B7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 根因: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) --- .../audit/service/AuditDenyWriter.java | 33 +++++++++++ .../security/AuthorizationAuditListener.java | 5 +- .../shared/security/DataScopeGuard.java | 11 +++- .../MatchmakerContactAuthorizationTest.java | 56 +++++++++++++++++++ .../AuthorizationAuditListenerTest.java | 3 +- .../security/V11MethodSecurityMatrixTest.java | 3 +- .../ServiceAppointmentDataScopeTest.java | 3 +- 7 files changed, 107 insertions(+), 7 deletions(-) create mode 100644 backend/src/main/java/com/danyang/match/platform/audit/service/AuditDenyWriter.java diff --git a/backend/src/main/java/com/danyang/match/platform/audit/service/AuditDenyWriter.java b/backend/src/main/java/com/danyang/match/platform/audit/service/AuditDenyWriter.java new file mode 100644 index 0000000..ad67b0a --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/audit/service/AuditDenyWriter.java @@ -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; + +/** + * 拒绝类审计的独立事务写入器(运行时实测修复)。 + * + *

背景:拒绝审计(DATA_SCOPE_DENIED / AUTHORIZATION_DENIED)原先与业务方法同一事务—— + * 拒绝时抛异常必然触发业务事务回滚,审计 insert(已执行 Updates:1)随之被回滚吞掉: + * 生产全量 deny 审计零落库。H2 集成测试因测试类 @Transactional(测试事务内可见未提交行) + * 结构性假绿。运行时实测(outsider consent → deny 行 0 条)抓到。 + * + *

语义: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); + } +} diff --git a/backend/src/main/java/com/danyang/match/platform/shared/security/AuthorizationAuditListener.java b/backend/src/main/java/com/danyang/match/platform/shared/security/AuthorizationAuditListener.java index 9ac9bad..5f91b65 100644 --- a/backend/src/main/java/com/danyang/match/platform/shared/security/AuthorizationAuditListener.java +++ b/backend/src/main/java/com/danyang/match/platform/shared/security/AuthorizationAuditListener.java @@ -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); } diff --git a/backend/src/main/java/com/danyang/match/platform/shared/security/DataScopeGuard.java b/backend/src/main/java/com/danyang/match/platform/shared/security/DataScopeGuard.java index 6839d66..8b99829 100644 --- a/backend/src/main/java/com/danyang/match/platform/shared/security/DataScopeGuard.java +++ b/backend/src/main/java/com/danyang/match/platform/shared/security/DataScopeGuard.java @@ -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); diff --git a/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerContactAuthorizationTest.java b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerContactAuthorizationTest.java index fe8396c..26cdb7f 100644 --- a/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerContactAuthorizationTest.java +++ b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerContactAuthorizationTest.java @@ -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() + .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() + .eq(AuditLog::getResourceId, auth.getId())); + auditLogMapper.delete(new LambdaQueryWrapper() + .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 审计"); } diff --git a/backend/src/test/java/com/danyang/match/platform/security/AuthorizationAuditListenerTest.java b/backend/src/test/java/com/danyang/match/platform/security/AuthorizationAuditListenerTest.java index 321b34f..25410b8 100644 --- a/backend/src/test/java/com/danyang/match/platform/security/AuthorizationAuditListenerTest.java +++ b/backend/src/test/java/com/danyang/match/platform/security/AuthorizationAuditListenerTest.java @@ -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, diff --git a/backend/src/test/java/com/danyang/match/platform/security/V11MethodSecurityMatrixTest.java b/backend/src/test/java/com/danyang/match/platform/security/V11MethodSecurityMatrixTest.java index 0974d48..b665ee7 100644 --- a/backend/src/test/java/com/danyang/match/platform/security/V11MethodSecurityMatrixTest.java +++ b/backend/src/test/java/com/danyang/match/platform/security/V11MethodSecurityMatrixTest.java @@ -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 diff --git a/backend/src/test/java/com/danyang/match/platform/service_appointment/ServiceAppointmentDataScopeTest.java b/backend/src/test/java/com/danyang/match/platform/service_appointment/ServiceAppointmentDataScopeTest.java index 48dd64a..7ff2a02 100644 --- a/backend/src/test/java/com/danyang/match/platform/service_appointment/ServiceAppointmentDataScopeTest.java +++ b/backend/src/test/java/com/danyang/match/platform/service_appointment/ServiceAppointmentDataScopeTest.java @@ -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