diff --git a/backend/src/main/java/com/danyang/match/platform/audit/entity/AuditLog.java b/backend/src/main/java/com/danyang/match/platform/audit/entity/AuditLog.java index e61bd86..b41b146 100644 --- a/backend/src/main/java/com/danyang/match/platform/audit/entity/AuditLog.java +++ b/backend/src/main/java/com/danyang/match/platform/audit/entity/AuditLog.java @@ -19,4 +19,15 @@ public class AuditLog extends BaseEntity { private String afterData; private String ip; private String userAgent; + + // ==================== ZC-MM-006 / T01 扩展审计字段(V055,全可空) ==================== + + /** 业务目的码,规范:matchmaker.service.assign/recommend/followup/complete/view。 */ + private String purposeCode; + /** 关联的 matchmaker_assignments.id(非分配场景为 null)。 */ + private String serviceAssignmentId; + /** 落库时操作者权限快照(permissions 逗号拼接,截断 128)。 */ + private String actorRoleSnapshot; + /** 请求追踪 ID(RequestIdFilter 写入 MDC 的 requestId)。 */ + private String requestTraceId; } diff --git a/backend/src/main/java/com/danyang/match/platform/audit/service/AuditLogService.java b/backend/src/main/java/com/danyang/match/platform/audit/service/AuditLogService.java index 0f770d6..02233eb 100644 --- a/backend/src/main/java/com/danyang/match/platform/audit/service/AuditLogService.java +++ b/backend/src/main/java/com/danyang/match/platform/audit/service/AuditLogService.java @@ -52,6 +52,11 @@ public class AuditLogService { m.put("ip", log.getIp()); m.put("userAgent", log.getUserAgent()); m.put("createdAt", log.getCreatedAt()); + // ZC-MM-006 / T01:扩展审计字段输出(可空键,存量行输出 null,读取兼容) + m.put("purposeCode", log.getPurposeCode()); + m.put("serviceAssignmentId", log.getServiceAssignmentId()); + m.put("actorRoleSnapshot", log.getActorRoleSnapshot()); + m.put("requestTraceId", log.getRequestTraceId()); return m; }).collect(java.util.stream.Collectors.toList()); @@ -73,11 +78,23 @@ public class AuditLogService { */ @Transactional public void recordSensitiveAccess(String operatorId, String resourceType, String resourceId) { + recordSensitiveAccess(operatorId, resourceType, resourceId, null, null); + } + + /** + * ZC-MM-006 / T01 扩展版敏感访问记录:附加目的码与关联分配 ID。 + * 旧 3 参签名委托本方法(purposeCode/serviceAssignmentId 为 null),存量调用兼容。 + */ + @Transactional + public void recordSensitiveAccess(String operatorId, String resourceType, String resourceId, + String purposeCode, String serviceAssignmentId) { AuditLog auditLog = new AuditLog(); auditLog.setOperatorId(operatorId); auditLog.setAction("SENSITIVE_ACCESS"); auditLog.setResourceType(resourceType); auditLog.setResourceId(resourceId); + auditLog.setPurposeCode(purposeCode); + auditLog.setServiceAssignmentId(serviceAssignmentId); if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attrs) { HttpServletRequest req = attrs.getRequest(); auditLog.setIp(req.getRemoteAddr()); diff --git a/backend/src/main/java/com/danyang/match/platform/auth/service/OperatorAuthService.java b/backend/src/main/java/com/danyang/match/platform/auth/service/OperatorAuthService.java index 3cf5813..47f94e1 100644 --- a/backend/src/main/java/com/danyang/match/platform/auth/service/OperatorAuthService.java +++ b/backend/src/main/java/com/danyang/match/platform/auth/service/OperatorAuthService.java @@ -101,6 +101,9 @@ public class OperatorAuthService { .toList(); // 签发 Token + // ZC-MM-006 / T02 声明:claims 中的 permissions 仅为登录时刻的展示快照(只写不读), + // **不是授权事实源** —— 服务端每请求实时查库(JwtAuthFilter → PermissionMapper), + // 权限回收/角色调整/账号禁用即时生效,无需等待 token 过期或撤销名单。 Map claims = new HashMap<>(); claims.put("operatorId", operator.getId()); claims.put("organizationId", operator.getOrganizationId()); diff --git a/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerAssignmentService.java b/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerAssignmentService.java index 65946f2..052ef78 100644 --- a/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerAssignmentService.java +++ b/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerAssignmentService.java @@ -82,8 +82,9 @@ public class MatchmakerAssignmentService { requireReason(reason, "分配理由必填"); Operator target = requireActiveOperator(matchmakerOperatorId, "目标红娘不存在"); + // create/assign 类无资源寻址端点维持 403(ZC-MM-006 / T04 口径) assertAssignScope(assignerOperatorId, hasGlobalAssign, matchmakerOperatorId, - "组长只能分配同组织的红娘(NULL=NULL 平台直属)", matchmakerOperatorId); + "组长只能分配同组织的红娘(NULL=NULL 平台直属)", matchmakerOperatorId, false); assignmentMapper.findActivePrimaryByUser(userId).ifPresent(existing -> { throw new BusinessException(ApiResponseCode.CONFLICT, @@ -135,12 +136,13 @@ public class MatchmakerAssignmentService { // 防御纵深(QA P2-1):不仅新目标红娘要在操作者范围内, // 被转记录的**原持有人**也必须在范围内(与 terminate 路径同口径; // 否则 org=X 组长可把 org=Y 红娘持有的分配"顺手"转走)。 + // ZC-MM-006 / T04:transfer 为资源寻址写端点,范围违规 404 化(防探测)。 assertAssignScope(assignerOperatorId, hasGlobalAssign, old.getMatchmakerOperatorId(), - "组长只能处置同组织红娘持有的分配(NULL=NULL 平台直属)", assignmentId); + "组长只能处置同组织红娘持有的分配(NULL=NULL 平台直属)", assignmentId, true); requireActiveOperator(newMatchmakerOperatorId, "目标红娘不存在"); assertAssignScope(assignerOperatorId, hasGlobalAssign, newMatchmakerOperatorId, - "组长只能转分配给同组织的红娘(NULL=NULL 平台直属)", assignmentId); + "组长只能转分配给同组织的红娘(NULL=NULL 平台直属)", assignmentId, true); // 1) 旧记录置 transferred old.setStatus(Constants.ASSIGNMENT_STATUS_TRANSFERRED); @@ -273,8 +275,9 @@ public class MatchmakerAssignmentService { assertActiveForTransition(assignment, actionLabel); // 范围校验:操作者必须有权处置该红娘的分配(assign 不限组织 / team.assign 同组织) + // ZC-MM-006 / T04:revoke/end 为资源寻址写端点,范围违规 404 化(防探测) assertAssignScope(operatorId, hasGlobalAssign, assignment.getMatchmakerOperatorId(), - "组长只能处置同组织红娘的分配(NULL=NULL 平台直属)", assignmentId); + "组长只能处置同组织红娘的分配(NULL=NULL 平台直属)", assignmentId, true); assignment.setStatus(targetStatus); assignment.setEndReason(reason); @@ -292,11 +295,18 @@ public class MatchmakerAssignmentService { * 组织范围判定(D2):hasGlobalAssign(matchmaker.assign)不限组织; * team.assign 时校验操作者与目标红娘 organization_id 一致。 * NULL=NULL 视为平台直属:平台直属组长只能分配平台直属红娘; - * 有组织组长不能分配 org=NULL 红娘。违规走 DataScopeGuard.deny(审计 + 403)。 + * 有组织组长不能分配 org=NULL 红娘。违规统一先落 DATA_SCOPE_DENIED 审计。 + * + *

拒绝口径(ZC-MM-006 / T04): + *

*/ private void assertAssignScope(String assignerOperatorId, boolean hasGlobalAssign, String targetOperatorId, String denyMessage, - String auditResourceId) { + String auditResourceId, boolean notFoundOnDeny) { if (hasGlobalAssign) { return; // 平台授权人员(matchmaker.assign),不限组织 } @@ -308,7 +318,12 @@ public class MatchmakerAssignmentService { if (Objects.equals(assigner.getOrganizationId(), target.getOrganizationId())) { return; } - dataScopeGuard.deny(assignerOperatorId, RESOURCE_TYPE, auditResourceId, denyMessage); + if (notFoundOnDeny) { + dataScopeGuard.denyAsNotFound(assignerOperatorId, RESOURCE_TYPE, auditResourceId, + "服务不存在", null, null); + } else { + dataScopeGuard.deny(assignerOperatorId, RESOURCE_TYPE, auditResourceId, denyMessage); + } } /** 服务单联动(同事务):仅 submitted 状态允许关联分配并置为 assigned。 */ diff --git a/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerServiceService.java b/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerServiceService.java index d6c2410..87a43b9 100644 --- a/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerServiceService.java +++ b/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerServiceService.java @@ -1,8 +1,11 @@ package com.danyang.match.platform.matchmaker.service; import com.danyang.match.platform.audit.entity.AuditLog; +import com.danyang.match.platform.audit.entity.SensitiveAccessLog; import com.danyang.match.platform.audit.mapper.AuditLogMapper; +import com.danyang.match.platform.audit.mapper.SensitiveAccessLogMapper; import com.danyang.match.platform.matchmaker.dto.MatchmakerRecommendationDTO; +import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment; import com.danyang.match.platform.matchmaker.entity.MatchmakerFollowup; import com.danyang.match.platform.matchmaker.entity.MatchmakerRecommendation; import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord; @@ -66,6 +69,7 @@ public class MatchmakerServiceService { private final UserPrivacySettingsMapper userPrivacySettingsMapper; private final NotificationService notificationService; private final AuditLogMapper auditLogMapper; + private final SensitiveAccessLogMapper sensitiveAccessLogMapper; private final ServiceAssignmentPolicy serviceAssignmentPolicy; private final MatchmakerAssignmentService matchmakerAssignmentService; @@ -136,7 +140,7 @@ public class MatchmakerServiceService { } // ① 创建分配(matchmaker.assign 入口为平台授权,hasGlobalAssign=true) - matchmakerAssignmentService.create(assignerOperatorId, true, + MatchmakerAssignment createdAssignment = matchmakerAssignmentService.create(assignerOperatorId, true, service.getUserId(), targetOperatorId, serviceId, reason, null); // ② 服务单联动(create 内已对 submitted 服务单置 assigned;此处再校验一次确保联动生效) @@ -147,7 +151,9 @@ public class MatchmakerServiceService { log.info("分配红娘服务: serviceId={}, targetOperatorId={}, by={}", serviceId, targetOperatorId, assignerOperatorId); - saveAuditLog(assignerOperatorId, "MATCHMAKER_ASSIGN", "matchmaker_service", serviceId, null, null); + // ZC-MM-006 / T01:审计补 purpose_code + service_assignment_id + saveAuditLog(assignerOperatorId, "MATCHMAKER_ASSIGN", "matchmaker_service", serviceId, null, null, + "matchmaker.service.assign", createdAssignment.getId()); return after; } @@ -176,7 +182,7 @@ public class MatchmakerServiceService { if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在"); // D4:归属校验 —— 只有该服务对象的负责人(分配持有人)才能推荐 - assertServiceAccess(operatorId, service); + MatchmakerAssignment assignment = assertServiceAccess(operatorId, service); // 不能推荐给自己(服务申请人) if (service.getUserId() != null && service.getUserId().equals(targetUserId)) { throw new IllegalArgumentException("不能推荐用户给自己"); @@ -199,7 +205,9 @@ public class MatchmakerServiceService { "matchmaker_recommendation", rec.getId().toString()); log.info("红娘推荐: serviceId={}, operatorId={}, targetUserId={}", serviceId, operatorId, targetUserId); - saveAuditLog(operatorId, "MATCHMAKER_RECOMMEND", "matchmaker_service", serviceId, null, null); + // ZC-MM-006 / T01:审计补 purpose_code + service_assignment_id + saveAuditLog(operatorId, "MATCHMAKER_RECOMMEND", "matchmaker_service", serviceId, null, null, + "matchmaker.service.recommend", assignment != null ? assignment.getId() : null); Map result = new HashMap<>(); result.put("recommendationId", rec.getId()); @@ -220,7 +228,7 @@ public class MatchmakerServiceService { String nextFollowupAt) { MatchmakerServiceRecord service = serviceMapper.selectById(serviceId); if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在"); - assertServiceAccess(operatorId, service); + MatchmakerAssignment followupAssignment = assertServiceAccess(operatorId, service); MatchmakerFollowup followup = new MatchmakerFollowup(); followup.setServiceId(serviceId); @@ -233,7 +241,9 @@ public class MatchmakerServiceService { followupMapper.insert(followup); log.info("红娘跟进: serviceId={}, operatorId={}, type={}", serviceId, operatorId, followupType); - saveAuditLog(operatorId, "MATCHMAKER_FOLLOWUP", "matchmaker_service", serviceId, null, null); + // ZC-MM-006 / T01:审计补 purpose_code + service_assignment_id + saveAuditLog(operatorId, "MATCHMAKER_FOLLOWUP", "matchmaker_service", serviceId, null, null, + "matchmaker.service.followup", followupAssignment != null ? followupAssignment.getId() : null); return followup; } @@ -247,7 +257,7 @@ public class MatchmakerServiceService { public MatchmakerServiceRecord completeService(String serviceId, String operatorId) { MatchmakerServiceRecord service = serviceMapper.selectById(serviceId); if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在"); - assertServiceAccess(operatorId, service); + MatchmakerAssignment completeAssignment = assertServiceAccess(operatorId, service); if (!Constants.MATCHMAKER_STATUS_IN_SERVICE.equals(service.getStatus()) && !Constants.MATCHMAKER_STATUS_WAITING_USER.equals(service.getStatus())) { throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不能完成"); @@ -258,7 +268,9 @@ public class MatchmakerServiceService { serviceMapper.updateById(service); log.info("红娘服务完成: serviceId={}", serviceId); - saveAuditLog(null, "MATCHMAKER_COMPLETE", "matchmaker_service", serviceId, null, null); + // ZC-MM-006 / T01:审计补 purpose_code + service_assignment_id + saveAuditLog(null, "MATCHMAKER_COMPLETE", "matchmaker_service", serviceId, null, null, + "matchmaker.service.complete", completeAssignment != null ? completeAssignment.getId() : null); return service; } @@ -363,6 +375,18 @@ public class MatchmakerServiceService { serviceAssignmentPolicy.assertActiveAssignmentOrNotFound( requesterId, service.getUserId(), "matchmaker_service", serviceId); + // ZC-MM-006 / T03:持有人读取服务对象详情 = 敏感数据访问,必须落 sensitive_access_logs + // (AGENTS.md #8:查看动作本身必须可溯源)。拒绝路径走判定器的 DATA_SCOPE_DENIED 审计, + // 双记录互斥 —— 非持有人不会走到本行(不重复写 SensitiveAccessLog)。 + SensitiveAccessLog accessLog = new SensitiveAccessLog(); + accessLog.setOperatorId(requesterId); + accessLog.setUserId(service.getUserId()); + accessLog.setDataType(Constants.SENSITIVE_DATA_TYPE_MATCHMAKER_SERVICE_DETAIL); + accessLog.setReason(Constants.SENSITIVE_ACCESS_REASON_MATCHMAKER_DETAIL); + accessLog.setAccessTarget(serviceId); + accessLog.setAccessResult(Constants.SENSITIVE_ACCESS_RESULT_SUCCESS); + sensitiveAccessLogMapper.insert(accessLog); + Map detail = new HashMap<>(); detail.put("serviceId", service.getId()); detail.put("serviceType", service.getServiceType()); @@ -439,16 +463,20 @@ public class MatchmakerServiceService { } /** - * 服务归属校验(ZC-MM-005 / E1 纯判定器版)。 + * 服务归属校验(ZC-MM-005 / E1 纯判定器版;ZC-MM-006 / T04 写接口 404 化)。 * *

无任何回退:matchmaker_assignments 是服务对象归属的唯一事实源。 * 002 的 assigned_operator_id 回退分支已删除 —— 分配 revoke/end 后服务单残留的 * assigned_operator_id 不再构成访问凭据(DEC-002 规则7:撤销即失权)。 - * 写接口口径:判定无效 → DataScopeGuard.deny(DATA_SCOPE_DENIED 审计 + 403)。 + * 写接口(recommend/followup/complete,资源寻址端点)口径:判定无效 → + * {@link ServiceAssignmentPolicy#assertActiveAssignmentOrNotFound}: + * 先落 DATA_SCOPE_DENIED 审计,再抛 404「服务不存在」—— 与读详情口径逐字段一致,防探测。 + * + * @return 生效中的分配记录(供审计写入 service_assignment_id;deny 必然抛出,不会返回 null) */ - private void assertServiceAccess(String operatorId, MatchmakerServiceRecord service) { - serviceAssignmentPolicy.assertActiveAssignment(operatorId, service.getUserId(), - "matchmaker_service"); + private MatchmakerAssignment assertServiceAccess(String operatorId, MatchmakerServiceRecord service) { + return serviceAssignmentPolicy.assertActiveAssignmentOrNotFound(operatorId, service.getUserId(), + "matchmaker_service", service.getId()); } @@ -490,11 +518,25 @@ public class MatchmakerServiceService { private void saveAuditLog(String operatorId, String action, String resourceType, String resourceId, String beforeData, String afterData) { + // ZC-MM-006 / T01 兼容性:缺省调用(不带 purposeCode/assignmentId)仍可写(负向用例覆盖) + saveAuditLog(operatorId, action, resourceType, resourceId, beforeData, afterData, null, null); + } + + /** + * ZC-MM-006 / T01 扩展版审计写入:附加 purpose_code 与 service_assignment_id。 + */ + private void saveAuditLog(String operatorId, String action, String resourceType, + String resourceId, String beforeData, String afterData, + String purposeCode, String serviceAssignmentId) { AuditLog auditLog = new AuditLog(); auditLog.setOperatorId(operatorId); auditLog.setAction(action); auditLog.setResourceType(resourceType); auditLog.setResourceId(resourceId); + auditLog.setBeforeData(beforeData); + auditLog.setAfterData(afterData); + auditLog.setPurposeCode(purposeCode); + auditLog.setServiceAssignmentId(serviceAssignmentId); auditLogMapper.insert(auditLog); } } diff --git a/backend/src/main/java/com/danyang/match/platform/matchmaker/service/ServiceAssignmentPolicy.java b/backend/src/main/java/com/danyang/match/platform/matchmaker/service/ServiceAssignmentPolicy.java index 955e696..2e6b8fb 100644 --- a/backend/src/main/java/com/danyang/match/platform/matchmaker/service/ServiceAssignmentPolicy.java +++ b/backend/src/main/java/com/danyang/match/platform/matchmaker/service/ServiceAssignmentPolicy.java @@ -1,20 +1,14 @@ package com.danyang.match.platform.matchmaker.service; -import com.danyang.match.platform.audit.entity.AuditLog; -import com.danyang.match.platform.audit.mapper.AuditLogMapper; import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment; import com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper; -import com.danyang.match.platform.shared.enums.ApiResponseCode; -import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; import com.danyang.match.platform.shared.security.DataScopeGuard; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -import java.time.Instant; import java.util.List; import java.util.Optional; -import java.util.UUID; /** * 服务对象分配判定器(ZC-MM-002 / A1,架构师设计 D6;ZC-MM-005 / E1-E2 增强)。 @@ -40,7 +34,6 @@ public class ServiceAssignmentPolicy { private final MatchmakerAssignmentMapper assignmentMapper; private final DataScopeGuard dataScopeGuard; - private final AuditLogMapper auditLogMapper; /** * 惰性过期:把 expires_at 已届满的 active 分配置为 expired 并落库。 @@ -88,7 +81,9 @@ public class ServiceAssignmentPolicy { * message:"服务不存在"}} —— 与查询一个真正不存在的 serviceId 的响应逐字段一致, * 调用方无法区分"无权"与"不存在",杜绝按 serviceId 枚举探测。 * - *

仅用于读详情路径;写接口维持 {@link #assertActiveAssignment} 的 403 现状。 + *

适用面(ZC-MM-006 / T04 扩展):读详情 + 带资源寻址的写端点 + * (recommend / followup / complete / 转介)范围校验失败统一 404 化(防探测); + * create / assign 类无资源寻址端点维持 {@link #assertActiveAssignment} 的 403 口径。 * * @param resourceType 审计 resourceType(如 matchmaker_service) * @param resourceId 审计 resourceId(被探测的 serviceId) @@ -101,20 +96,11 @@ public class ServiceAssignmentPolicy { if (found.isPresent()) { return found.get(); } - AuditLog audit = new AuditLog(); - audit.setId(UUID.randomUUID().toString()); - audit.setOperatorId(operatorId); - audit.setAction("DATA_SCOPE_DENIED"); - audit.setResourceType(resourceType); - audit.setResourceId(resourceId); - audit.setCreatedAt(Instant.now()); - try { - auditLogMapper.insert(audit); - } catch (RuntimeException exception) { - log.error("写入数据范围拒绝审计失败: operatorId={}, resourceType={}, resourceId={}", - operatorId, resourceType, resourceId, exception); - } - throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在"); + // ZC-MM-006 / T04:防探测 404 化 deny —— 审计与 404 抛出统一收敛到 + // {@link DataScopeGuard#denyAsNotFound}(审计含权限快照 + 请求追踪 ID)。 + // 该方法同时服务读详情与(本阶段起)写接口范围校验,拒绝口径全程一致。 + dataScopeGuard.denyAsNotFound(operatorId, resourceType, resourceId, "服务不存在", null, null); + return null; // denyAsNotFound 必然抛出,此行不可达 } /** 某红娘名下全部生效中的分配(GET /my 口径),读前惰性过期。 */ diff --git a/backend/src/main/java/com/danyang/match/platform/referral/service/ReferralService.java b/backend/src/main/java/com/danyang/match/platform/referral/service/ReferralService.java index f840fcc..72adeef 100644 --- a/backend/src/main/java/com/danyang/match/platform/referral/service/ReferralService.java +++ b/backend/src/main/java/com/danyang/match/platform/referral/service/ReferralService.java @@ -41,18 +41,22 @@ public class ReferralService { * * 修复后:先断言存在生效中的分配关系,否则拒绝。这样工作人员只能在真实的红娘服务关系内转介。 * - *

ZC-MM-005 收口(P1-3):授权事实源由 matchmaker_services.assigned_operator_id 残留列 - * ({@code findActiveByOperatorAndUser},按服务单 status 判定)切换为 - * {@link com.danyang.match.platform.matchmaker.service.ServiceAssignmentPolicy#canAccessUser} + *

ZC-MM-005 收口(P1-3):授权事实源切换为 + * {@link com.danyang.match.platform.matchmaker.service.ServiceAssignmentPolicy} * 判定器 —— 与写接口 recommend / followup 同口径(matchmaker_assignments 生效中分配)。 * 分配 revoke / end 后残留列不再构成转介凭据(DEC-002 规则7:撤销即失权)。 - * 拒绝统一走 {@code DataScopeGuard.deny}(DATA_SCOPE_DENIED 审计 + 403 AccessDeniedException)。 + * + *

ZC-MM-006 / T04(写接口 404 统一·防探测):转介为资源寻址写端点,拒绝路径 + * 404 化 {@code denyAsNotFound}(先 DATA_SCOPE_DENIED 审计,再抛 404「服务不存在」, + * 与读详情口径逐字段一致);并消灭裸 IllegalArgumentException —— + * 缺失目标用户也统一映射为同一 404 响应,不泄露"目标是否存在"的任何信号。 */ private void assertMatchmakerServesUser(String matchmakerId, String userId) { if (userId == null || userId.isBlank()) { - throw new IllegalArgumentException("缺少转介目标用户"); + throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在"); } - serviceAssignmentPolicy.assertActiveAssignment(matchmakerId, userId, "matchmaker_referral"); + serviceAssignmentPolicy.assertActiveAssignmentOrNotFound(matchmakerId, userId, + "matchmaker_referral", userId); } @Transactional diff --git a/backend/src/main/java/com/danyang/match/platform/shared/constant/Constants.java b/backend/src/main/java/com/danyang/match/platform/shared/constant/Constants.java index ede8f9b..0efdff7 100644 --- a/backend/src/main/java/com/danyang/match/platform/shared/constant/Constants.java +++ b/backend/src/main/java/com/danyang/match/platform/shared/constant/Constants.java @@ -354,4 +354,12 @@ public final class Constants { public static final String REVIEW_TYPE_LOCAL_ELIGIBILITY = "local_eligibility"; /** 本地资格佐证数据类型(用于敏感访问审计) */ public static final String SENSITIVE_DATA_TYPE_LOCAL_EVIDENCE = "local_eligibility_evidence"; + + // ========== ZC-MM-006 / T03 红娘域敏感访问审计 ========== + /** 红娘工作台服务对象详情数据类型(用于敏感访问审计) */ + public static final String SENSITIVE_DATA_TYPE_MATCHMAKER_SERVICE_DETAIL = "matchmaker_service_detail"; + /** 敏感访问成功结果(与 SensitiveAccessLog.accessResult 白名单 success/denied/failed 对齐) */ + public static final String SENSITIVE_ACCESS_RESULT_SUCCESS = "success"; + /** 红娘查看服务对象详情的访问理由文案 */ + public static final String SENSITIVE_ACCESS_REASON_MATCHMAKER_DETAIL = "matchmaker 工作台查看服务对象详情"; } \ No newline at end of file diff --git a/backend/src/main/java/com/danyang/match/platform/shared/filter/JwtAuthFilter.java b/backend/src/main/java/com/danyang/match/platform/shared/filter/JwtAuthFilter.java index af8b132..4d77c73 100644 --- a/backend/src/main/java/com/danyang/match/platform/shared/filter/JwtAuthFilter.java +++ b/backend/src/main/java/com/danyang/match/platform/shared/filter/JwtAuthFilter.java @@ -1,6 +1,7 @@ package com.danyang.match.platform.shared.filter; import com.danyang.match.platform.auth.token.JwtTokenService; +import com.danyang.match.platform.permission.mapper.OperatorMapper; import com.danyang.match.platform.permission.service.PermissionService; import com.danyang.match.platform.shared.security.principal.OperatorPrincipal; import com.danyang.match.platform.shared.security.principal.PlatformPrincipal; @@ -34,6 +35,7 @@ public class JwtAuthFilter extends OncePerRequestFilter { private final JwtTokenService jwtTokenService; private final PermissionService permissionService; + private final OperatorMapper operatorMapper; private static final List PERMITTED_PATHS = List.of( "/api/v1/auth/**", @@ -87,6 +89,20 @@ public class JwtAuthFilter extends OncePerRequestFilter { String subject = jwtTokenService.getUserIdFromToken(token); String tokenType = jwtTokenService.parseToken(token).get("type", String.class); boolean operatorToken = "operator_access".equals(tokenType); + if (operatorToken) { + // ZC-MM-006 / T02(JWT 权限回收·实时查库路线,不建撤销名单): + // 运营 token 每请求实时查 operators.status —— 账号不存在(含软删除)或非 active + // 一律 401、不建 principal,已签发 token 立即失效;零缓存。 + // 注意:此处只接受 "active";"normal" 是用户账号状态(ACCOUNT_STATUS_NORMAL), + // 运营账号禁用/待激活均不得通过过滤器。 + var operator = operatorMapper.findById(subject); + if (operator.isEmpty() || !"active".equals(operator.get().getStatus())) { + sendUnauthorized(response); + return; + } + } + // ZC-MM-006 / T02:权限实时查库(PermissionMapper join role_permissions/operator_roles), + // 每请求生效;token claims 中的 permissions 仅作展示快照(只写不读),不是授权事实源。 List permissions = operatorToken ? permissionService.getPermissions(subject) : List.of(); 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 7ece964..9ac9bad 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 @@ -35,6 +35,9 @@ public class AuthorizationAuditListener { audit.setAction("AUTHORIZATION_DENIED"); audit.setResourceType("authorization"); audit.setResourceId(limit(String.valueOf(event.getObject()), 128)); + // ZC-MM-006 / T01:权限快照(permissions 逗号拼接,截断 128)+ 请求追踪 ID + audit.setActorRoleSnapshot(limit(String.join(",", principal.permissions()), 128)); + audit.setRequestTraceId(org.slf4j.MDC.get("requestId")); audit.setCreatedAt(Instant.now()); if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attributes) { 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 a526e5e..8d3264c 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,13 +2,18 @@ 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.shared.enums.ApiResponseCode; +import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import java.time.Instant; import java.util.UUID; +import org.slf4j.MDC; /** Denies out-of-scope operator access and records the attempt before returning 403. */ @Slf4j @@ -19,12 +24,28 @@ public class DataScopeGuard { private final AuditLogMapper auditLogMapper; public void deny(String operatorId, String resourceType, String resourceId, String message) { + // ZC-MM-006 / T01 兼容性:缺省调用(不带 purposeCode/assignmentId)仍可写 + deny(operatorId, resourceType, resourceId, message, null, null); + } + + /** + * ZC-MM-006 / T01 扩展版拒绝审计:附加业务目的码与关联分配 ID。 + * + * @param purposeCode 业务目的码(如 matchmaker.service.view),可空 + * @param serviceAssignmentId 关联的 matchmaker_assignments.id(拒绝路径通常无生效分配 → null) + */ + public void deny(String operatorId, String resourceType, String resourceId, String message, + String purposeCode, String serviceAssignmentId) { AuditLog audit = new AuditLog(); audit.setId(UUID.randomUUID().toString()); audit.setOperatorId(operatorId); audit.setAction("DATA_SCOPE_DENIED"); audit.setResourceType(resourceType); audit.setResourceId(resourceId); + audit.setPurposeCode(purposeCode); + audit.setServiceAssignmentId(serviceAssignmentId); + audit.setActorRoleSnapshot(currentActorRoleSnapshot()); + audit.setRequestTraceId(MDC.get("requestId")); audit.setCreatedAt(Instant.now()); try { auditLogMapper.insert(audit); @@ -34,4 +55,57 @@ public class DataScopeGuard { } throw new AccessDeniedException(message); } -} \ No newline at end of file + + /** + * ZC-MM-006 / T04(写接口 404 统一·防探测):范围校验失败的写端点 —— + * 先落与 {@link #deny} 完全相同的 DATA_SCOPE_DENIED 审计(语义上仍是 403 越权), + * 再抛 {@code BusinessException(NOT_FOUND, "服务不存在")}:响应与"资源不存在"逐字段一致, + * 调用方无法区分"无权"与"不存在",杜绝按资源 ID 枚举探测写端点。 + * + *

适用面:带资源寻址的写端点(transfer/revoke/end/recommend/followup/complete/转介); + * create/assign 类无资源寻址端点维持 {@link #deny} 的 403 口径。 + * + * @param message 404 响应 message(统一 "服务不存在",与读详情口径一致) + */ + public void denyAsNotFound(String operatorId, String resourceType, String resourceId, String message, + String purposeCode, String serviceAssignmentId) { + AuditLog audit = new AuditLog(); + audit.setId(UUID.randomUUID().toString()); + audit.setOperatorId(operatorId); + audit.setAction("DATA_SCOPE_DENIED"); + audit.setResourceType(resourceType); + audit.setResourceId(resourceId); + audit.setPurposeCode(purposeCode); + audit.setServiceAssignmentId(serviceAssignmentId); + audit.setActorRoleSnapshot(currentActorRoleSnapshot()); + audit.setRequestTraceId(MDC.get("requestId")); + audit.setCreatedAt(Instant.now()); + try { + auditLogMapper.insert(audit); + } catch (RuntimeException exception) { + log.error("写入数据范围拒绝审计失败: operatorId={}, resourceType={}, resourceId={}", + operatorId, resourceType, resourceId, exception); + } + throw new BusinessException(ApiResponseCode.NOT_FOUND, message); + } + + /** + * 当前操作者权限快照(与 {@link AuthorizationAuditListener} 同口径): + * OperatorPrincipal.permissions() 逗号拼接,截断 128。 + */ + private String currentActorRoleSnapshot() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication != null && authentication.getPrincipal() + instanceof com.danyang.match.platform.shared.security.principal.OperatorPrincipal principal) { + return limit(String.join(",", principal.permissions()), 128); + } + return null; + } + + private String limit(String value, int maxLength) { + if (value == null || value.length() <= maxLength) { + return value; + } + return value.substring(0, maxLength); + } +} diff --git a/backend/src/main/resources/db/migration/V055__extend_audit_logs_for_matchmaker.sql b/backend/src/main/resources/db/migration/V055__extend_audit_logs_for_matchmaker.sql new file mode 100644 index 0000000..f431d3a --- /dev/null +++ b/backend/src/main/resources/db/migration/V055__extend_audit_logs_for_matchmaker.sql @@ -0,0 +1,18 @@ +-- ============================================================ +-- V055: audit_logs 扩展红娘域审计字段(ZC-MM-006 / T01) +-- +-- 新增 4 列(全部可空、存量不回填、语句幂等): +-- purpose_code 业务目的码,规范:matchmaker.service.assign/recommend/followup/complete/view +-- service_assignment_id 关联的 matchmaker_assignments.id(可空:非分配场景为 NULL) +-- actor_role_snapshot 审计落库时操作者的权限快照(OperatorPrincipal.permissions() 逗号拼接,截断 128) +-- request_trace_id 请求追踪 ID(RequestIdFilter 写入 MDC 的 requestId,16 位) +-- +-- 兼容性:全部可空 → 缺省调用(不传新字段)仍可正常写入,存量行为不变。 +-- 幂等:H2/PostgreSQL 均无 ADD COLUMN IF NOT EXISTS 统一保障时, +-- 用 information_schema 判空后执行(对照 V034/V035 风格)。 +-- ============================================================ + +ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS purpose_code VARCHAR(64); +ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS service_assignment_id UUID; +ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS actor_role_snapshot VARCHAR(128); +ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS request_trace_id VARCHAR(64); diff --git a/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java b/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java index 9bc1f25..f2f81da 100644 --- a/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java +++ b/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java @@ -34,7 +34,9 @@ class MigrationValidationTest { // 写入 canonical 键 identity.liveness.enabled=false(fail-safe 默认关闭) // ZC-MM-002(A1 服务对象分配建模):V052 补齐 matchmaker 角色权限(V052 其实早于本轮,漏计); // V053 matchmaker_assignments 建表 + 存量回填;V054 matchmaker_leader 角色与团队分配权限 - private static final int LATEST_MIGRATION = 54; + // ZC-MM-006(T01 审计字段扩展):V055 audit_logs 补 purpose_code / service_assignment_id / + // actor_role_snapshot / request_trace_id 四列(全可空、存量不回填、幂等) + private static final int LATEST_MIGRATION = 55; private static final String MIGRATION_DIR = "backend/src/main/resources/db/migration/"; diff --git a/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerActionGuardTest.java b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerActionGuardTest.java index 943142f..7a8b3c7 100644 --- a/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerActionGuardTest.java +++ b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerActionGuardTest.java @@ -79,11 +79,11 @@ class MatchmakerActionGuardTest { /** 测试环境无 Redis:黑名单查询打桩为"未拉黑"(ChatPhase09IntegrationTest 同款) */ @MockBean private TokenBlacklistService tokenBlacklistService; /** - * 测试 H2 库无 permissions/operator_roles 表:JwtAuthFilter 对 operator token 会调 - * PermissionService.getPermissions → 打桩为空权限(测试只断言 ROLE_OPERATOR 与 - * hasRole("USER") 的身份类型 403,不依赖具体权限装载)。 + * ZC-MM-006 / T02 起由真实 DB 路径接管权限装载(schema-h2 已补权限四表), + * 此处打桩仅为兼容既有断言口径(本测试只依赖身份类型 403,不依赖具体权限集)。 */ @MockBean private com.danyang.match.platform.permission.service.PermissionService permissionService; + @Autowired private com.danyang.match.platform.permission.mapper.OperatorMapper operatorMapper; @Autowired private MatchmakerServiceMapper matchmakerServiceMapper; @Autowired private MatchmakerAssignmentMapper matchmakerAssignmentMapper; @Autowired private MatchmakerRecommendationMapper recommendationMapper; @@ -125,6 +125,15 @@ class MatchmakerActionGuardTest { matchmakerAssignmentMapper.insert(assignment); // operator token:type=operator_access → JwtAuthFilter 授 ROLE_OPERATOR + // ZC-MM-006 / T02:JwtAuthFilter 实时查 operators.status,须为 token subject 落一条 active 运营行 + com.danyang.match.platform.permission.entity.Operator op = + new com.danyang.match.platform.permission.entity.Operator(); + op.setId(MATCHMAKER_OWNER); + op.setName("003/004 守卫红娘"); + op.setUsername("ag_mm_owner_" + System.nanoTime()); + op.setPasswordHash("test-hash"); + op.setStatus("active"); + operatorMapper.insert(op); when(permissionService.getPermissions(anyString())).thenReturn(List.of()); operatorToken = jwtTokenService.generateOperatorToken(MATCHMAKER_OWNER, null); } diff --git a/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerAssignmentA1Test.java b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerAssignmentA1Test.java index 38ca465..94d81a5 100644 --- a/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerAssignmentA1Test.java +++ b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerAssignmentA1Test.java @@ -442,15 +442,18 @@ class MatchmakerAssignmentA1Test { "有组织组长不得分配 org=NULL(平台直属)红娘"); assertEquals(before + 1, countAudits("DATA_SCOPE_DENIED", leaderA), "拒绝必须落 DATA_SCOPE_DENIED 审计"); - // 同理 transfer 的范围校验也覆盖该方向 + // 同理 transfer 的范围校验也覆盖该方向(ZC-MM-006 / T04:transfer 404 化) MatchmakerAssignment a = assignmentService.create(leaderA, false, user3, mmB, null, "主责", null); - assertThrows(AccessDeniedException.class, + var transferDenied = assertThrows( + com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class, () -> assignmentService.transfer(a.getId(), leaderA, false, platformMm, "转给平台直属红娘"), - "有组织组长不得把分配转给 org=NULL 红娘"); + "有组织组长不得把分配转给 org=NULL 红娘(404 化拒绝)"); + assertEquals(com.danyang.match.platform.shared.enums.ApiResponseCode.NOT_FOUND, transferDenied.getCode(), + "transfer 范围违规必须 404 化(防探测,T04)"); } @Test - @DisplayName("P2-1补充(负向): org=X 组长转分配 org=Y 红娘持有的分配 → 403 + DATA_SCOPE_DENIED 审计(原持有人范围校验)") + @DisplayName("P2-1补充(负向): org=X 组长转分配 org=Y 红娘持有的分配 → 404 化拒绝 + DATA_SCOPE_DENIED 审计(原持有人范围校验)") void leaderCannotTransferAssignmentHeldByCrossOrgMatchmaker() { // org=Y 红娘C 持有一条分配(由平台授权路径创建,绕过 create 的范围校验以构造前置状态) MatchmakerAssignment held = assignmentService.create( @@ -459,9 +462,13 @@ class MatchmakerAssignmentA1Test { long before = countAudits("DATA_SCOPE_DENIED", leaderA); // org=X 组长A 把 C(org=Y)持有的分配转给同组织成员 B:原持有人范围违规,必须拒绝 - assertThrows(AccessDeniedException.class, + // (ZC-MM-006 / T04:transfer 为资源寻址写端点,拒绝 404 化,审计仍为 DATA_SCOPE_DENIED) + var denied = assertThrows( + com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class, () -> assignmentService.transfer(held.getId(), leaderA, false, mmB, "转回本组织"), - "组长不得处置跨组织红娘持有的分配(原持有人范围校验)"); + "组长不得处置跨组织红娘持有的分配(原持有人范围校验,404 化)"); + assertEquals(com.danyang.match.platform.shared.enums.ApiResponseCode.NOT_FOUND, denied.getCode(), + "transfer 范围违规必须 404 化(防探测,T04)"); assertEquals(before + 1, countAudits("DATA_SCOPE_DENIED", leaderA), "拒绝必须落 DATA_SCOPE_DENIED 审计"); diff --git a/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerAuditFieldsTest.java b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerAuditFieldsTest.java new file mode 100644 index 0000000..5351d2d --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerAuditFieldsTest.java @@ -0,0 +1,203 @@ +package com.danyang.match.platform.matchmaker; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.danyang.match.platform.audit.entity.AuditLog; +import com.danyang.match.platform.audit.mapper.AuditLogMapper; +import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment; +import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord; +import com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper; +import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper; +import com.danyang.match.platform.matchmaker.service.MatchmakerAssignmentService; +import com.danyang.match.platform.matchmaker.service.MatchmakerServiceService; +import com.danyang.match.platform.permission.entity.Operator; +import com.danyang.match.platform.permission.mapper.OperatorMapper; +import com.danyang.match.platform.shared.constant.Constants; +import com.danyang.match.platform.shared.enums.ApiResponseCode; +import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; +import com.danyang.match.platform.shared.security.principal.OperatorPrincipal; +import com.danyang.match.platform.user.entity.User; +import com.danyang.match.platform.user.mapper.UserMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * ZC-MM-006 / T01 验收:audit_logs 扩展字段(V055)写入链路。 + * + *

覆盖: + *

    + *
  • 扩展写入(正向):assign / recommend 审计行落 purpose_code + service_assignment_id;
  • + *
  • 兼容性(负向·缺省调用):applyService 走旧签名(purposeCode/assignmentId 缺省)仍可写, + * purpose_code 为 null —— 证明扩展列全可空、存量行为不变;
  • + *
  • 拒绝路径(负向):无分配 recommend → AccessDeniedException + DATA_SCOPE_DENIED 审计, + * 且审计行带 actor_role_snapshot(SecurityContext 权限快照)+ request_trace_id(MDC)。
  • + *
+ */ +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class MatchmakerAuditFieldsTest { + + @Autowired private MatchmakerServiceService matchmakerServiceService; + @Autowired private MatchmakerAssignmentService assignmentService; + @Autowired private MatchmakerServiceMapper serviceMapper; + @Autowired private MatchmakerAssignmentMapper assignmentMapper; + @Autowired private OperatorMapper operatorMapper; + @Autowired private UserMapper userMapper; + @Autowired private AuditLogMapper auditLogMapper; + + private String matchmakerId; + private String userId; + + @BeforeEach + void setUp() { + matchmakerId = createOperator(); + userId = createUser(); + } + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + MDC.remove("requestId"); + } + + // ==================== 数据构造(Phase13 范式) ==================== + + private String createOperator() { + Operator op = new Operator(); + op.setName("审计字段测试红娘"); + op.setUsername("audit_fields_" + System.nanoTime()); + op.setPasswordHash("test-hash"); + op.setStatus("active"); + operatorMapper.insert(op); + return op.getId(); + } + + private String createUser() { + User u = new User(); + u.setWechatOpenid("audit_fields_" + System.nanoTime()); + u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL); + userMapper.insert(u); + return u.getId(); + } + + private String createService(String operatorId) { + MatchmakerServiceRecord svc = new MatchmakerServiceRecord(); + svc.setUserId(userId); + svc.setServiceType(Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER); + svc.setDescription("审计字段测试服务单"); + svc.setStatus(Constants.MATCHMAKER_STATUS_SUBMITTED); + svc.setAssignedOperatorId(operatorId); + serviceMapper.insert(svc); + return svc.getId(); + } + + private void authenticate(String operatorId, String... permissions) { + Set permSet = Set.of(permissions); + var authorities = Stream.concat( + Stream.of(new SimpleGrantedAuthority("ROLE_OPERATOR")), + permSet.stream().map(SimpleGrantedAuthority::new)).toList(); + SecurityContextHolder.getContext().setAuthentication( + UsernamePasswordAuthenticationToken.authenticated( + new OperatorPrincipal(operatorId, permSet), null, authorities)); + } + + private AuditLog latestAudit(String action, String resourceId) { + List rows = auditLogMapper.selectList(new LambdaQueryWrapper() + .eq(AuditLog::getAction, action) + .eq(AuditLog::getResourceId, resourceId) + .orderByDesc(AuditLog::getCreatedAt)); + return rows.isEmpty() ? null : rows.get(0); + } + + // ==================== 正向:扩展写入 ==================== + + @Test + @DisplayName("T01 正向: assignService 审计行落 purpose_code=matchmaker.service.assign + service_assignment_id") + void assignAuditCarriesPurposeCodeAndAssignmentId() { + String serviceId = createService(matchmakerId); + + matchmakerServiceService.assignService(serviceId, matchmakerId, matchmakerId, "T01 分配审计"); + + MatchmakerAssignment assignment = assignmentMapper + .findActiveByOperatorAndUser(matchmakerId, userId).orElseThrow(); + AuditLog audit = latestAudit("MATCHMAKER_ASSIGN", serviceId); + assertNotNull(audit, "分配审计行必须存在"); + assertEquals("matchmaker.service.assign", audit.getPurposeCode(), "purpose_code 必须落库"); + assertEquals(assignment.getId(), audit.getServiceAssignmentId(), + "service_assignment_id 必须关联新建分配"); + } + + @Test + @DisplayName("T01 正向: recommendPartner 审计行落 purpose_code=matchmaker.service.recommend + 分配 ID") + void recommendAuditCarriesPurposeCodeAndAssignmentId() { + String serviceId = createService(matchmakerId); + MatchmakerAssignment assignment = assignmentService.create(matchmakerId, true, + userId, matchmakerId, serviceId, "T01 前置分配", null); + + matchmakerServiceService.recommendPartner(serviceId, matchmakerId, + "audit-target-" + System.nanoTime(), "T01 推荐审计"); + + AuditLog audit = latestAudit("MATCHMAKER_RECOMMEND", serviceId); + assertNotNull(audit, "推荐审计行必须存在"); + assertEquals("matchmaker.service.recommend", audit.getPurposeCode()); + assertEquals(assignment.getId(), audit.getServiceAssignmentId()); + } + + // ==================== 负向·兼容性:缺省调用仍可写 ==================== + + @Test + @DisplayName("T01 负向(兼容): 旧签名审计调用(applyService)不传 purposeCode/assignmentId 仍成功写入,扩展列为 null") + void legacyAuditCallStillWritableWithNullExtendedFields() { + String serviceId = matchmakerServiceService + .applyService(userId, Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER, "T01 兼容性申请") + .get("serviceId").toString(); + + AuditLog audit = latestAudit("MATCHMAKER_APPLY", serviceId); + assertNotNull(audit, "缺省调用(旧签名)审计行必须仍可写入"); + assertNull(audit.getPurposeCode(), "缺省调用 purpose_code 必须为 null(全可空)"); + assertNull(audit.getServiceAssignmentId(), "缺省调用 service_assignment_id 必须为 null"); + } + + // ==================== 负向:拒绝路径带快照 + 追踪 ID ==================== + + @Test + @DisplayName("T01 负向: 无分配 recommend → 404 化拒绝(T04)+ DATA_SCOPE_DENIED 审计含权限快照与追踪 ID") + void deniedAuditCarriesActorRoleSnapshotAndTraceId() { + String serviceId = createService(matchmakerId); + // 无分配的红娘(matchmakerId 对 userId 无生效分配)+ SecurityContext 携带权限 → 快照非空 + authenticate(matchmakerId, "matchmaker.service"); + MDC.put("requestId", "trace01test0001"); + + BusinessException denied = assertThrows(BusinessException.class, + () -> matchmakerServiceService.recommendPartner(serviceId, matchmakerId, + "audit-target-x", "T01 拒绝路径"), + "无生效分配必须拒绝(ZC-MM-006 / T04 起为 404 化拒绝)"); + assertEquals(ApiResponseCode.NOT_FOUND, denied.getCode(), + "写接口范围违规 404 化(T04 防探测)"); + + AuditLog audit = latestAudit("DATA_SCOPE_DENIED", serviceId); + assertNotNull(audit, "拒绝路径必须落 DATA_SCOPE_DENIED 审计"); + assertEquals("matchmaker.service", audit.getActorRoleSnapshot(), + "快照必须来自 SecurityContext 权限(逗号拼接,截断 128)"); + assertEquals("trace01test0001", audit.getRequestTraceId(), + "追踪 ID 必须取自 MDC(requestId)"); + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerDataScope005Test.java b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerDataScope005Test.java index 7fd5725..08e60dc 100644 --- a/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerDataScope005Test.java +++ b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerDataScope005Test.java @@ -472,7 +472,7 @@ class MatchmakerDataScope005Test { // ==================== P1-3:转介授权切判定器(写路径) ==================== @Test - @DisplayName("P1-3: 转介授权走判定器 —— 有效分配内正常转介(正向);revoke/无分配 → 403 + DATA_SCOPE_DENIED 审计(负向)") + @DisplayName("P1-3: 转介授权走判定器 —— 有效分配内正常转介(正向);revoke/无分配 → 404 化拒绝 + DATA_SCOPE_DENIED 审计(负向)") void referralAuthorizationFollowsActiveAssignments() { String serviceId = createService(user1, mmB); String assignmentId = createAssignment(mmB, user1, serviceId); @@ -481,23 +481,26 @@ class MatchmakerDataScope005Test { Map ok = referralService.referToActivity(mmB, user1, "act-005-p13", "紧急联系人", "enc"); assertNotNull(ok.get("registrationId"), "有效分配内转介必须成功落库"); - // 负向①:无任何分配的用户 → 403 + 审计 + // 负向①:无任何分配的用户 → 404 化拒绝(T04 防探测)+ 审计 long before = countDeniedAuditsForResource(mmB, user2); - assertThrows(AccessDeniedException.class, + BusinessException denied1 = assertThrows(BusinessException.class, () -> referralService.referToActivity(mmB, user2, "act-005-p14", "紧急联系人", "enc"), "无生效分配的红娘不得对任意用户发起转介"); + assertEquals(ApiResponseCode.NOT_FOUND, denied1.getCode(), "拒绝必须 404 化(防探测)"); + assertEquals("服务不存在", denied1.getMessage()); assertEquals(before + 1, countDeniedAuditsForResource(mmB, user2), "越权转介必须先落 DATA_SCOPE_DENIED 审计"); - // 负向②:revoke 后原对象转介 → 403 + 审计(残留 assigned_operator_id 不再构成转介凭据) + // 负向②:revoke 后原对象转介 → 404 化拒绝 + 审计(残留 assigned_operator_id 不再构成转介凭据) assignmentService.revoke(assignmentId, leaderA, false, "005 P1-3 撤销验证"); assertEquals(mmB, serviceMapper.selectById(serviceId).getAssignedOperatorId(), "前置确认:服务单残留 assigned_operator_id"); long beforeRevoked = countDeniedAuditsForResource(mmB, user1); - assertThrows(AccessDeniedException.class, + BusinessException denied2 = assertThrows(BusinessException.class, () -> referralService.referToServiceStation(mmB, user1, "loc-005-p13", "counselor_consultation", null, "备注"), - "撤销分配后红娘对原对象发起服务站转介必须 403"); + "撤销分配后红娘对原对象发起服务站转介必须拒绝"); + assertEquals(ApiResponseCode.NOT_FOUND, denied2.getCode()); assertEquals(beforeRevoked + 1, countDeniedAuditsForResource(mmB, user1)); } diff --git a/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerReferralPhase13Test.java b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerReferralPhase13Test.java index 29ad7a7..d92f24f 100644 --- a/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerReferralPhase13Test.java +++ b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerReferralPhase13Test.java @@ -303,28 +303,29 @@ class MatchmakerReferralPhase13Test { @DisplayName("D5: 非服务负责人的红娘不能转介该用户(转介须挂在真实服务关系上)") void otherMatchmakerCannotReferUser() { // MATCHMAKER_OTHER 与该用户之间没有任何服务关系 - // ZC-MM-005 收口(P1-3):转介授权切换为 ServiceAssignmentPolicy 判定器后, - // 拒绝从裸 IllegalStateException 改为 DataScopeGuard(DATA_SCOPE_DENIED 审计 + 403) - assertThrows(org.springframework.security.access.AccessDeniedException.class, + // ZC-MM-005 收口(P1-3):转介授权切换为 ServiceAssignmentPolicy 判定器; + // ZC-MM-006 / T04:转介为资源寻址写端点,拒绝 404 化(先 DATA_SCOPE_DENIED 审计, + // 再抛 404「服务不存在」,与"查无"逐字段一致,防探测) + assertThrows(com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class, () -> referralService.referToActivity( MATCHMAKER_OTHER, user, "act-p13-004", "联系人", "enc"), - "非该用户服务负责人的红娘不得发起转介"); + "非该用户服务负责人的红娘不得发起转介(404 化拒绝)"); - assertThrows(org.springframework.security.access.AccessDeniedException.class, + assertThrows(com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class, () -> referralService.referToServiceStation( MATCHMAKER_OTHER, user, "loc-p13-003", "counselor_consultation", null, null), - "非该用户服务负责人的红娘不得发起服务站转介"); + "非该用户服务负责人的红娘不得发起服务站转介(404 化拒绝)"); } @Test @DisplayName("D3: 非服务负责人的红娘不能向该服务添加推荐(归属校验)") void otherMatchmakerCannotRecommend() { - // ZC-MM-002(D4):归属判定切换到 ServiceAssignmentPolicy 后, - // 拒绝方式从裸 IllegalStateException 改为 DataScopeGuard(审计 + AccessDeniedException) - assertThrows(org.springframework.security.access.AccessDeniedException.class, + // ZC-MM-002(D4):归属判定切换到 ServiceAssignmentPolicy; + // ZC-MM-006 / T04:写接口拒绝 404 化(DATA_SCOPE_DENIED 审计 + BusinessException 404) + assertThrows(com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class, () -> matchmakerServiceService.recommendPartner( serviceId, MATCHMAKER_OTHER, target, "越权推荐"), - "非服务负责人不得添加推荐"); + "非服务负责人不得添加推荐(404 化拒绝)"); assertTrue(recommendationMapper.findByService(serviceId).isEmpty(), "越权推荐失败后不得留下任何推荐记录"); diff --git a/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerSensitiveAccessAuditTest.java b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerSensitiveAccessAuditTest.java new file mode 100644 index 0000000..0eba6c1 --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerSensitiveAccessAuditTest.java @@ -0,0 +1,181 @@ +package com.danyang.match.platform.matchmaker; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.danyang.match.platform.audit.entity.AuditLog; +import com.danyang.match.platform.audit.entity.SensitiveAccessLog; +import com.danyang.match.platform.audit.mapper.AuditLogMapper; +import com.danyang.match.platform.audit.mapper.SensitiveAccessLogMapper; +import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment; +import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord; +import com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper; +import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper; +import com.danyang.match.platform.matchmaker.service.MatchmakerAssignmentService; +import com.danyang.match.platform.matchmaker.service.MatchmakerServiceService; +import com.danyang.match.platform.permission.entity.Operator; +import com.danyang.match.platform.permission.mapper.OperatorMapper; +import com.danyang.match.platform.shared.constant.Constants; +import com.danyang.match.platform.shared.enums.ApiResponseCode; +import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; +import com.danyang.match.platform.user.entity.User; +import com.danyang.match.platform.user.mapper.UserMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * ZC-MM-006 / T03 验收:SensitiveAccessLog 接入红娘服务对象详情。 + * + *

语义(AGENTS.md #8:敏感数据默认拒绝,查看动作本身必须可溯源): + *

    + *
  • 持有人读 GET /operator/matchmaker/services/{id}(getServiceDetail)→ + * sensitive_access_logs 落 success 行(dataType=matchmaker_service_detail);
  • + *
  • 拒绝路径维持 audit_logs DATA_SCOPE_DENIED(判定器写入),不重复写 + * SensitiveAccessLog —— 双记录互斥;
  • + *
  • /my 与 /team/statistics 不接入敏感访问日志(无服务对象敏感明细)。
  • + *
+ * + *

覆盖:正例 success 行;负例(非持有人)无 SensitiveAccessLog 行且 audit_logs 有 + * DATA_SCOPE_DENIED;连读 N 次落 N 行(幂等逐次记录,不聚合去重)。 + */ +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class MatchmakerSensitiveAccessAuditTest { + + @Autowired private MatchmakerServiceService matchmakerServiceService; + @Autowired private MatchmakerAssignmentService assignmentService; + @Autowired private MatchmakerServiceMapper serviceMapper; + @Autowired private MatchmakerAssignmentMapper assignmentMapper; + @Autowired private OperatorMapper operatorMapper; + @Autowired private UserMapper userMapper; + @Autowired private SensitiveAccessLogMapper sensitiveAccessLogMapper; + @Autowired private AuditLogMapper auditLogMapper; + + private String holderId; // 分配持有人(红娘 B) + private String outsiderId; // 无分配红娘 C + private String userId; + + @BeforeEach + void setUp() { + holderId = createOperator("敏感审计持有人"); + outsiderId = createOperator("敏感审计外人"); + userId = createUser(); + } + + // ==================== 数据构造(Phase13 范式) ==================== + + private String createOperator(String name) { + Operator op = new Operator(); + op.setName(name); + op.setUsername("sa_" + name + "_" + System.nanoTime()); + op.setPasswordHash("test-hash"); + op.setStatus("active"); + operatorMapper.insert(op); + return op.getId(); + } + + private String createUser() { + User u = new User(); + u.setWechatOpenid("sa_user_" + System.nanoTime()); + u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL); + userMapper.insert(u); + return u.getId(); + } + + private String createServiceWithAssignment() { + MatchmakerServiceRecord svc = new MatchmakerServiceRecord(); + svc.setUserId(userId); + svc.setServiceType(Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER); + svc.setDescription("敏感访问审计测试服务单"); + svc.setStatus(Constants.MATCHMAKER_STATUS_SUBMITTED); + svc.setAssignedOperatorId(holderId); + serviceMapper.insert(svc); + assignmentService.create(holderId, true, userId, holderId, svc.getId(), + "T03 前置分配", null); + return svc.getId(); + } + + private List accessRows(String operatorId, String serviceId) { + return sensitiveAccessLogMapper.selectList(new LambdaQueryWrapper() + .eq(SensitiveAccessLog::getOperatorId, operatorId) + .eq(SensitiveAccessLog::getDataType, Constants.SENSITIVE_DATA_TYPE_MATCHMAKER_SERVICE_DETAIL) + .eq(SensitiveAccessLog::getAccessTarget, serviceId)); + } + + // ==================== 正例:持有人读详情落 success 行 ==================== + + @Test + @DisplayName("T03 正例: 持有人读服务详情 → sensitive_access_logs 落 success 行(dataType/target/reason 齐全)") + void holderReadDetailWritesSensitiveAccessSuccessRow() { + String serviceId = createServiceWithAssignment(); + + Map detail = matchmakerServiceService.getServiceDetail(serviceId, holderId); + assertNotNull(detail); + + List rows = accessRows(holderId, serviceId); + assertEquals(1, rows.size(), "持有人读详情必须落恰好 1 行敏感访问日志"); + SensitiveAccessLog row = rows.get(0); + assertEquals(Constants.SENSITIVE_DATA_TYPE_MATCHMAKER_SERVICE_DETAIL, row.getDataType()); + assertEquals(Constants.SENSITIVE_ACCESS_RESULT_SUCCESS, row.getAccessResult()); + assertEquals(Constants.SENSITIVE_ACCESS_REASON_MATCHMAKER_DETAIL, row.getReason()); + assertEquals(userId, row.getUserId()); + assertEquals(serviceId, row.getAccessTarget()); + } + + // ==================== 负例:非持有人读 → 双记录互斥 ==================== + + @Test + @DisplayName("T03 负例: 非持有人读详情 404 → 无 SensitiveAccessLog 行,audit_logs 有 DATA_SCOPE_DENIED") + void outsiderReadDetailWritesNoSensitiveAccessRowButDeniedAudit() { + String serviceId = createServiceWithAssignment(); + + BusinessException e = assertThrows(BusinessException.class, + () -> matchmakerServiceService.getServiceDetail(serviceId, outsiderId), + "非持有人读详情必须 404(防探测口径)"); + assertEquals(ApiResponseCode.NOT_FOUND, e.getCode()); + + // 双记录互斥:拒绝路径只写 audit_logs,不写 sensitive_access_logs + assertTrue(accessRows(outsiderId, serviceId).isEmpty(), + "拒绝路径不得写 SensitiveAccessLog(与 DATA_SCOPE_DENIED 互斥)"); + Long deniedAudits = auditLogMapper.selectCount(new LambdaQueryWrapper() + .eq(AuditLog::getAction, "DATA_SCOPE_DENIED") + .eq(AuditLog::getOperatorId, outsiderId) + .eq(AuditLog::getResourceId, serviceId)); + assertEquals(1L, deniedAudits, "拒绝路径必须落 DATA_SCOPE_DENIED 审计"); + } + + // ==================== 幂等逐次记录:连读 N 次落 N 行 ==================== + + @Test + @DisplayName("T03 幂等: 持有人连读 3 次 → 落 3 行(逐次记录,不聚合去重)") + void repeatedReadsWriteRowPerAccess() { + String serviceId = createServiceWithAssignment(); + + matchmakerServiceService.getServiceDetail(serviceId, holderId); + matchmakerServiceService.getServiceDetail(serviceId, holderId); + matchmakerServiceService.getServiceDetail(serviceId, holderId); + + assertEquals(3, accessRows(holderId, serviceId).size(), + "每次查看必须逐次记录(可溯源,不允许聚合去重)"); + } + + // ==================== 边界:查无 serviceId 不写敏感日志 ==================== + + @Test + @DisplayName("T03 边界: 查无 serviceId → 404,不落任何敏感访问行") + void missingServiceWritesNoSensitiveAccessRow() { + assertThrows(BusinessException.class, + () -> matchmakerServiceService.getServiceDetail("sa-no-such-service", holderId)); + assertTrue(accessRows(holderId, "sa-no-such-service").isEmpty(), + "查无资源不构成敏感访问,不得落行"); + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerWriteDenyUniformityTest.java b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerWriteDenyUniformityTest.java new file mode 100644 index 0000000..3553271 --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerWriteDenyUniformityTest.java @@ -0,0 +1,284 @@ +package com.danyang.match.platform.matchmaker; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.danyang.match.platform.audit.entity.AuditLog; +import com.danyang.match.platform.audit.mapper.AuditLogMapper; +import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment; +import com.danyang.match.platform.matchmaker.entity.MatchmakerFollowup; +import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord; +import com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper; +import com.danyang.match.platform.matchmaker.mapper.MatchmakerFollowupMapper; +import com.danyang.match.platform.matchmaker.mapper.MatchmakerRecommendationMapper; +import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper; +import com.danyang.match.platform.matchmaker.service.MatchmakerAssignmentService; +import com.danyang.match.platform.matchmaker.service.MatchmakerServiceService; +import com.danyang.match.platform.permission.entity.Operator; +import com.danyang.match.platform.permission.mapper.OperatorMapper; +import com.danyang.match.platform.referral.service.ReferralService; +import com.danyang.match.platform.shared.constant.Constants; +import com.danyang.match.platform.shared.enums.ApiResponseCode; +import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; +import com.danyang.match.platform.user.entity.User; +import com.danyang.match.platform.user.mapper.UserMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * ZC-MM-006 / T04 验收:写接口 404 统一(防探测)。 + * + *

语义:带资源寻址的写端点(transfer / revoke / end / recommend / followup / complete / + * 转介)范围校验失败时,先落 DATA_SCOPE_DENIED 审计,再抛 404「服务不存在」—— + * 响应与"资源不存在"(读详情 404)逐字段一致,调用方无法区分"无权"与"不存在", + * 杜绝按资源 ID 枚举探测写端点。create / assign 类无资源寻址端点维持 403(不在本测试面)。 + * + *

覆盖:负向×5(跨组织 transfer / 无分配 recommend / followup / complete / 非负责人转介) + * + 正向×2(持有人 recommend / followup 正常)+ 五个拒绝响应与读详情 404 逐字段一致。 + */ +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class MatchmakerWriteDenyUniformityTest { + + @Autowired private MatchmakerServiceService matchmakerServiceService; + @Autowired private MatchmakerAssignmentService assignmentService; + @Autowired private ReferralService referralService; + @Autowired private MatchmakerServiceMapper serviceMapper; + @Autowired private MatchmakerAssignmentMapper assignmentMapper; + @Autowired private MatchmakerRecommendationMapper recommendationMapper; + @Autowired private MatchmakerFollowupMapper followupMapper; + @Autowired private OperatorMapper operatorMapper; + @Autowired private UserMapper userMapper; + @Autowired private AuditLogMapper auditLogMapper; + + private String leaderA; // 组长,org X + private String mmB; // 红娘,org X(持有人) + private String mmC; // 红娘,org Y(跨组织) + private String userId; + + private static final String ORG_X = "44444444-4444-4444-4444-444444444441"; + private static final String ORG_Y = "44444444-4444-4444-4444-444444444442"; + + @BeforeEach + void setUp() { + leaderA = createOperator("组长A-006", ORG_X); + mmB = createOperator("红娘B-006", ORG_X); + mmC = createOperator("红娘C-006", ORG_Y); + userId = createUser(); + } + + // ==================== 数据构造(Phase13 范式) ==================== + + private String createOperator(String name, String organizationId) { + Operator op = new Operator(); + op.setName(name); + op.setOrganizationId(organizationId); + op.setUsername("wu_" + name + "_" + System.nanoTime()); + op.setPasswordHash("test-hash"); + op.setStatus("active"); + operatorMapper.insert(op); + return op.getId(); + } + + private String createUser() { + User u = new User(); + u.setWechatOpenid("wu_user_" + System.nanoTime()); + u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL); + userMapper.insert(u); + return u.getId(); + } + + private String createService(String operatorId) { + MatchmakerServiceRecord svc = new MatchmakerServiceRecord(); + svc.setUserId(userId); + svc.setServiceType(Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER); + svc.setDescription("写接口 404 统一测试服务单"); + svc.setStatus(Constants.MATCHMAKER_STATUS_SUBMITTED); + svc.setAssignedOperatorId(operatorId); + serviceMapper.insert(svc); + return svc.getId(); + } + + /** 平台授权路径创建分配(绕过组织范围校验以构造前置状态) */ + private String createAssignment(String operatorId, String serviceId) { + MatchmakerAssignment a = assignmentService.create(leaderA, true, userId, operatorId, + serviceId, "006 测试前置分配", null); + return a.getId(); + } + + private long deniedAudits(String operatorId, String resourceId) { + return auditLogMapper.selectCount(new LambdaQueryWrapper() + .eq(AuditLog::getAction, "DATA_SCOPE_DENIED") + .eq(AuditLog::getOperatorId, operatorId) + .eq(AuditLog::getResourceId, resourceId)); + } + + /** 读详情 404 基准("查无"路径)—— 五个写拒绝必须与其逐字段一致 */ + private BusinessException readDetailNotFoundBaseline() { + try { + matchmakerServiceService.getServiceDetail("wu-no-such-service", mmB); + } catch (BusinessException e) { + return e; + } + throw new AssertionError("预期读详情 404 基准异常"); + } + + // ==================== 负向×5:写拒绝 404 化 ==================== + + @Test + @DisplayName("T04 负向①: 跨组织 transfer → 404「服务不存在」+ DATA_SCOPE_DENIED 审计") + void crossOrgTransferDeniedAsNotFound() { + String serviceId = createService(mmB); + String assignmentId = createAssignment(mmB, serviceId); + + long before = deniedAudits(leaderA, assignmentId); + BusinessException e = assertThrows(BusinessException.class, + () -> assignmentService.transfer(assignmentId, leaderA, false, mmC, "跨组织转分配"), + "跨组织 transfer 必须拒绝"); + + assertEquals(ApiResponseCode.NOT_FOUND, e.getCode()); + assertEquals("服务不存在", e.getMessage()); + assertEquals(before + 1, deniedAudits(leaderA, assignmentId), "必须先落 DATA_SCOPE_DENIED 审计"); + // 被转记录原样保留 + assertEquals(Constants.ASSIGNMENT_STATUS_ACTIVE, + assignmentMapper.findById(assignmentId).orElseThrow().getStatus(), + "被拒绝的转分配不得改变原分配状态"); + } + + @Test + @DisplayName("T04 负向②: 无分配 recommend → 404「服务不存在」+ 审计") + void recommendWithoutAssignmentDeniedAsNotFound() { + String serviceId = createService(mmB); + createAssignment(mmB, serviceId); + + long before = deniedAudits(mmC, serviceId); + BusinessException e = assertThrows(BusinessException.class, + () -> matchmakerServiceService.recommendPartner(serviceId, mmC, "wu-target", "无权推荐")); + + assertEquals(ApiResponseCode.NOT_FOUND, e.getCode()); + assertEquals("服务不存在", e.getMessage()); + assertEquals(before + 1, deniedAudits(mmC, serviceId)); + assertTrue(recommendationMapper.findByService(serviceId).isEmpty(), + "拒绝的推荐不得落库"); + } + + @Test + @DisplayName("T04 负向③: 无分配 followup → 404「服务不存在」+ 审计") + void followupWithoutAssignmentDeniedAsNotFound() { + String serviceId = createService(mmB); + createAssignment(mmB, serviceId); + + long before = deniedAudits(mmC, serviceId); + BusinessException e = assertThrows(BusinessException.class, + () -> matchmakerServiceService.addFollowup(serviceId, mmC, "note", "越权跟进", null)); + + assertEquals(ApiResponseCode.NOT_FOUND, e.getCode()); + assertEquals("服务不存在", e.getMessage()); + assertEquals(before + 1, deniedAudits(mmC, serviceId)); + assertTrue(followupMapper.findByServiceId(serviceId).isEmpty(), + "拒绝的跟进不得落库"); + } + + @Test + @DisplayName("T04 负向④: 无分配 complete → 404「服务不存在」+ 审计") + void completeWithoutAssignmentDeniedAsNotFound() { + String serviceId = createService(mmB); + createAssignment(mmB, serviceId); + + long before = deniedAudits(mmC, serviceId); + BusinessException e = assertThrows(BusinessException.class, + () -> matchmakerServiceService.completeService(serviceId, mmC)); + + assertEquals(ApiResponseCode.NOT_FOUND, e.getCode()); + assertEquals("服务不存在", e.getMessage()); + assertEquals(before + 1, deniedAudits(mmC, serviceId)); + assertNotEquals(Constants.MATCHMAKER_STATUS_COMPLETED, + serviceMapper.selectById(serviceId).getStatus(), + "拒绝的完成动作不得改变服务单状态"); + } + + @Test + @DisplayName("T04 负向⑤: 非负责人转介 → 404「服务不存在」+ 审计(含裸异常消灭回归)") + void referralWithoutAssignmentDeniedAsNotFound() { + String serviceId = createService(mmB); + createAssignment(mmB, serviceId); + + long before = deniedAudits(mmC, userId); + BusinessException e1 = assertThrows(BusinessException.class, + () -> referralService.referToActivity(mmC, userId, "act-006-t04", "紧急联系人", "enc")); + assertEquals(ApiResponseCode.NOT_FOUND, e1.getCode()); + assertEquals("服务不存在", e1.getMessage()); + + BusinessException e2 = assertThrows(BusinessException.class, + () -> referralService.referToServiceStation(mmC, userId, "loc-006-t04", + "counselor_consultation", null, null)); + assertEquals(ApiResponseCode.NOT_FOUND, e2.getCode()); + assertEquals(before + 2, deniedAudits(mmC, userId), "两次越权转介各落一条审计"); + } + + // ==================== 正向×2:持有人正常 ==================== + + @Test + @DisplayName("T04 正向①: 持有人 recommend 正常(200 语义)") + void holderRecommendSucceeds() { + String serviceId = createService(mmB); + createAssignment(mmB, serviceId); + + Map result = matchmakerServiceService.recommendPartner( + serviceId, mmB, "wu-target-ok", "T04 持有人推荐"); + assertNotNull(result.get("recommendationId")); + } + + @Test + @DisplayName("T04 正向②: 持有人 followup 正常(200 语义)") + void holderFollowupSucceeds() { + String serviceId = createService(mmB); + createAssignment(mmB, serviceId); + + MatchmakerFollowup followup = matchmakerServiceService.addFollowup( + serviceId, mmB, "note", "T04 持有人跟进", null); + assertNotNull(followup.getId()); + } + + // ==================== 逐字段一致:五个写拒绝 ≡ 读详情 404 ==================== + + @Test + @DisplayName("T04 统一性: 五个写拒绝的 code+message 与读详情 404(查无基准)逐字段一致") + void writeDeniesIndistinguishableFromReadNotFound() { + String serviceId = createService(mmB); + String assignmentId = createAssignment(mmB, serviceId); + + BusinessException baseline = readDetailNotFoundBaseline(); + + BusinessException transferDenied = assertThrows(BusinessException.class, + () -> assignmentService.transfer(assignmentId, leaderA, false, mmC, "跨组织")); + BusinessException recommendDenied = assertThrows(BusinessException.class, + () -> matchmakerServiceService.recommendPartner(serviceId, mmC, "wu-t", "r")); + BusinessException followupDenied = assertThrows(BusinessException.class, + () -> matchmakerServiceService.addFollowup(serviceId, mmC, "note", "f", null)); + BusinessException completeDenied = assertThrows(BusinessException.class, + () -> matchmakerServiceService.completeService(serviceId, mmC)); + BusinessException referralDenied = assertThrows(BusinessException.class, + () -> referralService.referToActivity(mmC, userId, "act-006-u", "联系人", "enc")); + + List denies = List.of(transferDenied, recommendDenied, + followupDenied, completeDenied, referralDenied); + for (BusinessException denied : denies) { + assertEquals(baseline.getCode(), denied.getCode(), + "业务码必须与读详情 404 一致(逐字段防探测)"); + assertEquals(baseline.getMessage(), denied.getMessage(), + "message 必须与读详情 404 一致(逐字段防探测)"); + } + assertEquals(ApiResponseCode.NOT_FOUND, baseline.getCode()); + assertEquals("服务不存在", baseline.getMessage()); + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/security/OperatorEndpointPermissionCoverageTest.java b/backend/src/test/java/com/danyang/match/platform/security/OperatorEndpointPermissionCoverageTest.java index 0c91549..9d24d2d 100644 --- a/backend/src/test/java/com/danyang/match/platform/security/OperatorEndpointPermissionCoverageTest.java +++ b/backend/src/test/java/com/danyang/match/platform/security/OperatorEndpointPermissionCoverageTest.java @@ -23,6 +23,7 @@ import com.danyang.match.platform.user.controller.UserController; import org.junit.jupiter.api.Test; import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.RequestMapping; import java.lang.reflect.Method; @@ -97,6 +98,117 @@ class OperatorEndpointPermissionCoverageTest { "扫描到的运营端接口数量异常偏少(" + operatorEndpointCount + "),守卫可能已失效"); } + // ==================== ZC-MM-006 / T05 守卫补强 ==================== + + /** + * T05-a:类级 @RequestMapping 前缀合成完整路径后再判 /operator/** 归属。 + * + *

防护场景:运营端控制器普遍用类级 @RequestMapping("/operator/xxx") + 方法级 + * @GetMapping("/yyy") —— 单看方法路径不含 /operator 前缀。若后续把路径从类级挪到 + * 方法级(或新增控制器时前缀写错),本用例通过「类前缀 + 方法路径」合成完整路径逐条 + * 判定,保证归属判定不因 mapping 写法迁移而漂移。 + */ + @Test + void classLevelPrefixMustComposeIntoFullOperatorPath() { + int composedCount = 0; + List violations = new ArrayList<>(); + for (Class controller : CONTROLLERS) { + List classPaths = paths(controller); + if (classPaths.isEmpty() || classPaths.stream().noneMatch(this::isOperatorPath)) { + continue; + } + for (Method method : controller.getDeclaredMethods()) { + List methodPaths = paths(method); + if (methodPaths.isEmpty()) { + continue; // 非端点 + } + for (String methodPath : methodPaths) { + String fullPath = composePath(classPaths.get(0), methodPath); + composedCount++; + if (!isOperatorPath(fullPath)) { + violations.add(controller.getSimpleName() + " → " + fullPath); + } + } + } + } + org.junit.jupiter.api.Assertions.assertTrue(composedCount >= 10, + "合成路径样本过少(" + composedCount + "),守卫可能已失效"); + assertEquals(List.of(), violations, + "类级 /operator 前缀 + 方法路径合成的完整路径必须仍判为运营端归属"); + } + + /** + * T05-b:裸 @PreAuthorize 检查 —— 缺类级 @PreAuthorize 的控制器,其运营端接口 + * 必须逐方法显式授权(未全覆盖 → 红)。 + * + *

防护场景:带类级 @PreAuthorize 的控制器(如 MatchmakerOperatorController)允许 + * 个别方法不重复标注;但若有人日后删掉类级注解,这些"裸"方法即失去权限粒度 —— + * 本用例在"类级已缺失"的状态下强制逐方法覆盖,删注解动作立即变红。 + */ + @Test + void controllerWithoutClassLevelPreAuthorizeMustCoverEveryOperatorEndpoint() { + List violations = new ArrayList<>(); + for (Class controller : CONTROLLERS) { + boolean classLevel = AnnotatedElementUtils.hasAnnotation(controller, PreAuthorize.class); + for (Method method : controller.getDeclaredMethods()) { + List methodPaths = paths(method); + if (methodPaths.isEmpty()) { + continue; // 非端点 + } + boolean operatorFacing = paths(controller).stream().anyMatch(this::isOperatorPath) + || methodPaths.stream().anyMatch(this::isOperatorPath) + || Arrays.stream(method.getParameterTypes()).anyMatch(OperatorPrincipal.class::equals); + if (!operatorFacing) { + continue; + } + if (!classLevel && !AnnotatedElementUtils.hasAnnotation(method, PreAuthorize.class)) { + violations.add(controller.getSimpleName() + "." + method.getName()); + } + } + } + assertEquals(List.of(), violations, + "缺类级 @PreAuthorize 的控制器,其每个运营端接口必须自带方法级 @PreAuthorize"); + } + + /** + * T05-c:matchmaker / referral 包禁止 @PatchMapping。 + * + *

背景(ZC-MM-003/004 治理口径):红娘域全部状态迁移走显式语义端点 + * (accept/pass/confirm/transfer/revoke/end 等),每个动作独立审计与状态机校验。 + * PATCH 部分更新语义会绕过「动作 → 状态机 + 审计」链路,出现即视为架构违规。 + */ + @Test + void matchmakerAndReferralPackagesMustNotUsePatchMapping() { + List violations = new ArrayList<>(); + for (Class controller : CONTROLLERS) { + String pkg = controller.getPackageName(); + if (!pkg.contains(".matchmaker.") && !pkg.contains(".referral.")) { + continue; + } + if (AnnotatedElementUtils.findMergedAnnotation(controller, PatchMapping.class) != null) { + violations.add(controller.getSimpleName() + " (class-level)"); + } + for (Method method : controller.getDeclaredMethods()) { + if (AnnotatedElementUtils.findMergedAnnotation(method, PatchMapping.class) != null) { + violations.add(controller.getSimpleName() + "." + method.getName()); + } + } + } + assertEquals(List.of(), violations, + "matchmaker/referral 包禁止 @PatchMapping(部分更新绕过状态机+审计链路)"); + } + + /** 类级前缀 + 方法路径合成完整路径(Spring mapping 拼接语义)。 */ + private String composePath(String classPath, String methodPath) { + if (methodPath == null || methodPath.isEmpty()) { + return classPath; + } + if (methodPath.startsWith("/")) { + return classPath + methodPath; + } + return classPath + "/" + methodPath; + } + private List paths(java.lang.reflect.AnnotatedElement element) { RequestMapping mapping = AnnotatedElementUtils.findMergedAnnotation(element, RequestMapping.class); return mapping == null ? List.of() : Arrays.asList(mapping.path().length > 0 diff --git a/backend/src/test/java/com/danyang/match/platform/security/OperatorPermissionRevocationTest.java b/backend/src/test/java/com/danyang/match/platform/security/OperatorPermissionRevocationTest.java new file mode 100644 index 0000000..34871f6 --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/security/OperatorPermissionRevocationTest.java @@ -0,0 +1,198 @@ +package com.danyang.match.platform.security; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.danyang.match.platform.audit.entity.AuditLog; +import com.danyang.match.platform.audit.mapper.AuditLogMapper; +import com.danyang.match.platform.auth.token.JwtTokenService; +import com.danyang.match.platform.auth.token.TokenBlacklistService; +import com.danyang.match.platform.permission.entity.Operator; +import com.danyang.match.platform.permission.entity.Permission; +import com.danyang.match.platform.permission.mapper.OperatorMapper; +import com.danyang.match.platform.permission.mapper.PermissionMapper; +import org.springframework.jdbc.core.JdbcTemplate; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * ZC-MM-006 / T02 验收:JWT 权限回收(实时查库路线,不建撤销名单)。 + * + *

语义:operator token 每请求实时查库 —— ① operators.status 非 active(含账号删除) + * → 401、不建 principal;② 权限由 PermissionMapper join role_permissions/operator_roles + * 实时装载,DB 撤销权限后已签发 token 立即失去对应能力(403 + AUTHORIZATION_DENIED 审计)。 + * token claims 中的 permissions 仅为登录时刻快照,不是授权事实源(只写不读)。 + * + *

覆盖: + *

    + *
  • 正例:DB 授权(role_permissions)→ token 调 dashboard 200;
  • + *
  • 负向①:DB UPDATE status='disabled' → 同一 token → 401;
  • + *
  • 负向②:DB 删 role_permissions 行 → 同一 token 调 recommend → 403 + * + AUTHORIZATION_DENIED 审计(含 actor_role_snapshot 权限快照);
  • + *
  • 负向③(回归):用户 token 打 operator 路径 → 403(USER fallback 语义不回归)。
  • + *
+ */ +@SpringBootTest +@ActiveProfiles("test") +@Transactional +@AutoConfigureMockMvc +class OperatorPermissionRevocationTest { + + @Autowired private MockMvc mockMvc; + @Autowired private JwtTokenService jwtTokenService; + @Autowired private OperatorMapper operatorMapper; + @Autowired private PermissionMapper permissionMapper; + @Autowired private JdbcTemplate jdbcTemplate; + @Autowired private AuditLogMapper auditLogMapper; + /** 测试环境无 Redis:黑名单查询打桩为"未拉黑"(ChatPhase09IntegrationTest 同款) */ + @MockBean private TokenBlacklistService tokenBlacklistService; + + private String operatorId; + private String operatorToken; + private String servicePermissionId; + + @BeforeEach + void setUp() { + // 真实 DB 授权链:operator → operator_roles → role → role_permissions → permissions + operatorId = createOperator(); + String roleId = createRole("rev_role_" + System.nanoTime()); + String viewPermissionId = createPermission("matchmaker.view"); + servicePermissionId = createPermission("matchmaker.service"); + grant(roleId, viewPermissionId); + grant(roleId, servicePermissionId); + bindOperatorRole(operatorId, roleId); + operatorToken = jwtTokenService.generateOperatorToken(operatorId, + Map.of("permissions", java.util.List.of("matchmaker.view", "matchmaker.service"))); + } + + @AfterEach + void clearSecurityContext() { + org.springframework.security.core.context.SecurityContextHolder.clearContext(); + } + + // ==================== 数据构造 ==================== + + private String createOperator() { + Operator op = new Operator(); + op.setName("权限回收测试运营"); + op.setUsername("revoke_" + System.nanoTime()); + op.setPasswordHash("test-hash"); + op.setStatus("active"); + operatorMapper.insert(op); + return op.getId(); + } + + private String createRole(String code) { + String roleId = UUID.randomUUID().toString(); + jdbcTemplate.update("INSERT INTO roles (id, code, name, system_role) VALUES (?, ?, ?, ?)", + roleId, code, "权限回收测试角色", false); + return roleId; + } + + private String createPermission(String code) { + Permission p = new Permission(); + p.setCode(code); + p.setName("权限回收测试权限 " + code); + p.setModule("matchmaker"); + permissionMapper.insert(p); + return p.getId(); + } + + private void grant(String roleId, String permissionId) { + jdbcTemplate.update( + "INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)", + roleId, permissionId); + } + + private void bindOperatorRole(String operatorId, String roleId) { + jdbcTemplate.update( + "INSERT INTO operator_roles (operator_id, role_id) VALUES (?, ?)", + operatorId, roleId); + } + + // ==================== 正例 ==================== + + @Test + @DisplayName("T02 正例: DB 授权运营持 token 调 dashboard → 200") + void dbAuthorizedOperatorCanCallDashboard() throws Exception { + mockMvc.perform(get("/operator/matchmaker/dashboard") + .header("Authorization", "Bearer " + operatorToken)) + .andExpect(status().isOk()); + } + + // ==================== 负向①:禁用账号即时 401 ==================== + + @Test + @DisplayName("T02 负向①: DB UPDATE status='disabled' 后同一 token → 401(实时查库,零缓存)") + void disabledOperatorTokenImmediatelyUnauthorized() throws Exception { + // 前置:禁用前可正常调用 + mockMvc.perform(get("/operator/matchmaker/dashboard") + .header("Authorization", "Bearer " + operatorToken)) + .andExpect(status().isOk()); + + Operator op = operatorMapper.findById(operatorId).orElseThrow(); + op.setStatus("disabled"); + operatorMapper.updateById(op); + + // 同一 token(未过期、无撤销名单)→ 过滤器实时查库拒绝 + mockMvc.perform(get("/operator/matchmaker/dashboard") + .header("Authorization", "Bearer " + operatorToken)) + .andExpect(status().isUnauthorized()); + } + + // ==================== 负向②:DB 撤权即时 403 + 审计 ==================== + + @Test + @DisplayName("T02 负向②: DB 撤销 role_permissions 后同一 token 调 recommend → 403 + AUTHORIZATION_DENIED 审计(含快照)") + void revokedPermissionImmediatelyForbiddenWithAudit() throws Exception { + // 撤销 matchmaker.service 授权(matchmaker.view 保留 → 类级 @PreAuthorize 仍过、方法级拒绝) + jdbcTemplate.update("DELETE FROM role_permissions WHERE permission_id = ?", servicePermissionId); + + long deniedCount = auditLogMapper.selectCount(new LambdaQueryWrapper() + .eq(AuditLog::getAction, "AUTHORIZATION_DENIED")); + assertTrue(deniedCount <= 1, "前置确认:本测试事务内拒绝审计至多前置 1 条"); + + mockMvc.perform(post("/operator/matchmaker/services/{id}/recommend", "rev-no-such-svc") + .header("Authorization", "Bearer " + operatorToken) + .contentType("application/json") + .content("{\"targetUserId\":\"rev-target\",\"reason\":\"T02 撤权验证\"}")) + .andExpect(status().isForbidden()); + + AuditLog denied = auditLogMapper.selectList(new LambdaQueryWrapper() + .eq(AuditLog::getAction, "AUTHORIZATION_DENIED") + .eq(AuditLog::getOperatorId, operatorId) + .orderByDesc(AuditLog::getCreatedAt)) + .stream().findFirst().orElseThrow(() -> + new AssertionError("权限回收后的调用必须落 AUTHORIZATION_DENIED 审计")); + // actor_role_snapshot:来自实时查库装载的权限集合(matchmaker.view 仍在) + assertEquals("matchmaker.view", denied.getActorRoleSnapshot(), + "拒绝审计必须携带落库时刻的权限快照(实时查库口径,不含已撤销的 matchmaker.service)"); + } + + // ==================== 负向③:用户 token 打 operator 路径(回归) ==================== + + @Test + @DisplayName("T02 负向③: 用户 token 打 operator 路径 → 403(USER fallback 语义不回归)") + void userTokenCannotReachOperatorPath() throws Exception { + String userToken = jwtTokenService.generateAccessToken( + "rev-user-" + UUID.randomUUID(), Map.of()); + mockMvc.perform(get("/operator/matchmaker/dashboard") + .header("Authorization", "Bearer " + userToken)) + .andExpect(status().isForbidden()); + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/shared/filter/JwtAuthFilterTest.java b/backend/src/test/java/com/danyang/match/platform/shared/filter/JwtAuthFilterTest.java index 5b6f02e..7d0b791 100644 --- a/backend/src/test/java/com/danyang/match/platform/shared/filter/JwtAuthFilterTest.java +++ b/backend/src/test/java/com/danyang/match/platform/shared/filter/JwtAuthFilterTest.java @@ -1,6 +1,8 @@ package com.danyang.match.platform.shared.filter; import com.danyang.match.platform.auth.token.JwtTokenService; +import com.danyang.match.platform.permission.entity.Operator; +import com.danyang.match.platform.permission.mapper.OperatorMapper; import com.danyang.match.platform.permission.service.PermissionService; import com.danyang.match.platform.shared.security.principal.OperatorPrincipal; import com.danyang.match.platform.shared.security.principal.UserPrincipal; @@ -27,9 +29,19 @@ class JwtAuthFilterTest { @Mock private JwtTokenService jwtTokenService; @Mock private PermissionService permissionService; + /** ZC-MM-006 / T02:运营分支新增实时查库(operators.status),单测同步打桩 */ + @Mock private OperatorMapper operatorMapper; @Mock private Claims claims; @InjectMocks private JwtAuthFilter filter; + private Operator activeOperator(String id) { + Operator op = new Operator(); + op.setId(id); + op.setName("单测运营"); + op.setStatus("active"); + return op; + } + @AfterEach void clearSecurityContext() { SecurityContextHolder.clearContext(); @@ -42,6 +54,7 @@ class JwtAuthFilterTest { when(jwtTokenService.getUserIdFromToken(token)).thenReturn("operator-1"); when(jwtTokenService.parseToken(token)).thenReturn(claims); when(claims.get("type", String.class)).thenReturn("operator_access"); + when(operatorMapper.findById("operator-1")).thenReturn(java.util.Optional.of(activeOperator("operator-1"))); when(permissionService.getPermissions("operator-1")) .thenReturn(List.of("content.edit", "content.publish")); @@ -109,6 +122,7 @@ class JwtAuthFilterTest { when(jwtTokenService.getUserIdFromToken(token)).thenReturn("operator-1"); when(jwtTokenService.parseToken(token)).thenReturn(claims); when(claims.get("type", String.class)).thenReturn("operator_access"); + when(operatorMapper.findById("operator-1")).thenReturn(java.util.Optional.of(activeOperator("operator-1"))); when(permissionService.getPermissions("operator-1")).thenReturn(List.of("content.edit")); filter.doFilter(cookieRequest(token), new MockHttpServletResponse(), new MockFilterChain()); diff --git a/backend/src/test/resources/schema-h2.sql b/backend/src/test/resources/schema-h2.sql index 96550e8..a7f085a 100644 --- a/backend/src/test/resources/schema-h2.sql +++ b/backend/src/test/resources/schema-h2.sql @@ -108,7 +108,10 @@ CREATE TABLE audit_logs ( id VARCHAR(36) PRIMARY KEY, operator_id VARCHAR(36), organization_id VARCHAR(36), action VARCHAR(128), resource_type VARCHAR(64), resource_id VARCHAR(128), before_data CLOB, after_data CLOB, ip VARCHAR(64), user_agent VARCHAR(500), created_at TIMESTAMP WITH TIME ZONE, - updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE + updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE, + -- V055(ZC-MM-006 / T01):扩展审计字段,全可空,与生产对齐 + purpose_code VARCHAR(64), service_assignment_id VARCHAR(36), + actor_role_snapshot VARCHAR(128), request_trace_id VARCHAR(64) ); CREATE TABLE notifications ( id VARCHAR(36) PRIMARY KEY, user_id VARCHAR(36), type VARCHAR(32), title VARCHAR(200), content VARCHAR(2000), @@ -547,6 +550,35 @@ CREATE TABLE operators ( ); CREATE UNIQUE INDEX uk_operator_username_h2 ON operators(username); +-- V001 配套(ZC-MM-006 / T02):权限四表 —— JwtAuthFilter 运营分支实时查库 +-- (PermissionMapper join role_permissions/operator_roles)在 H2 上的真实 DB 路径支撑; +-- 此前缺失导致所有 operator token 测试只能 @MockBean PermissionService 绕过。 +-- 与生产 V001 对齐、按惯例去外键。 +CREATE TABLE roles ( + id VARCHAR(36) PRIMARY KEY, code VARCHAR(64) NOT NULL, name VARCHAR(100) NOT NULL, + description VARCHAR(500), system_role BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMP WITH TIME ZONE +); +CREATE UNIQUE INDEX uk_role_code_h2 ON roles(code); +CREATE TABLE permissions ( + id VARCHAR(36) PRIMARY KEY, code VARCHAR(128) NOT NULL, name VARCHAR(200) NOT NULL, + module VARCHAR(64) NOT NULL, description VARCHAR(500), + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMP WITH TIME ZONE +); +CREATE UNIQUE INDEX uk_permission_code_h2 ON permissions(code); +CREATE TABLE role_permissions ( + role_id VARCHAR(36) NOT NULL, permission_id VARCHAR(36) NOT NULL, + PRIMARY KEY (role_id, permission_id) +); +CREATE TABLE operator_roles ( + operator_id VARCHAR(36) NOT NULL, role_id VARCHAR(36) NOT NULL, + PRIMARY KEY (operator_id, role_id) +); + -- Phase 17:举报与风控三表(与 V012__add_risk_and_report_tables.sql 生产 DDL 对齐)。 -- 此前完全缺失 → ReportService / RiskReferralService 整条真实 DB 路径零覆盖, -- 且 ReportServiceTest 只能写 assertTrue(true) 假绿。已补入并移出 TEST_SCHEMA_OPTIONAL_TABLES 白名单。 diff --git a/scripts/run-backend-contracts.mjs b/scripts/run-backend-contracts.mjs index dcfc55b..d96d2dd 100644 --- a/scripts/run-backend-contracts.mjs +++ b/scripts/run-backend-contracts.mjs @@ -45,6 +45,11 @@ const backendTestRoot = resolve(root, 'backend', 'src', 'test', 'java') // 若 @RequestBody 被再度收窄为 Map,数组值反序列化失败即 400 // (现象:「小程序提交资料后,管理员点通过直接报错」)。 // ReviewControllerRequestBodyContractTest +// 第 15~18 项(2026-10-11,ZC-MM-006): +// MatchmakerAuditFieldsTest —— audit_logs 扩展审计字段(V055)写入/兼容/拒绝快照 +// OperatorPermissionRevocationTest —— JWT 权限回收实时查库(禁用 401 / 撤权 403+审计) +// MatchmakerSensitiveAccessAuditTest —— 服务详情敏感访问日志(success 行 / 双记录互斥) +// MatchmakerWriteDenyUniformityTest —— 写接口 404 统一防探测(5 拒绝 ≡ 读详情 404) // 新增/删减请同步修改本数组(下限随之变化)。 const CURATED = [ 'AuthServiceRefreshTtlTest', @@ -61,6 +66,10 @@ const CURATED = [ 'PhotoUploadValidationTest', 'OperatorAuthServiceTest', 'ReviewControllerRequestBodyContractTest', + 'MatchmakerAuditFieldsTest', + 'OperatorPermissionRevocationTest', + 'MatchmakerSensitiveAccessAuditTest', + 'MatchmakerWriteDenyUniformityTest', ] const MIN_GUARDS = Number(process.env.BACKEND_GUARDS_MIN ?? CURATED.length)