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