ZC-MM-006 审计与权限回收(阶段4)
- T01: V055 audit_logs 扩展 4 字段(purpose_code/service_assignment_id/actor_role_snapshot/request_trace_id, 全可空幂等) + AuditLog/DataScopeGuard/AuthorizationAuditListener/MatchmakerServiceService 写入链路改造 + schema-h2 同步 + LATEST_MIGRATION 54→55 - T02: JwtAuthFilter 运营分支改实时查库权限回收(每请求查 operators.status + 权限, 停用即401/撤权即403, 零缓存, claims 权限降级只写不读), 不碰 SecurityConfig/JwtTokenService/用户端 - T03: getServiceDetail 持有人分支接入 SensitiveAccessLog(成功查看落行 dataType=matchmaker_service_detail, 拒绝路径只落 audit_logs DATA_SCOPE_DENIED, 双记录结构性互斥) - T04: 写接口 404 统一防探测(transfer/revoke/end/recommend/followup/complete/转介范围违规 404 化 deny, 先审计后 404, create/assign 无资源寻址维持 403, ReferralService 消灭裸 IllegalArgumentException) - T05: OperatorEndpointPermissionCoverageTest +3 守卫(类级 RequestMapping 前缀合成/缺类级则逐方法 PreAuthorize/matchmaker+referral 禁 PatchMapping) + run-backend-contracts CURATED 14→18 - 新增测试 4 类 20 用例: MatchmakerAuditFieldsTest / OperatorPermissionRevocationTest / MatchmakerSensitiveAccessAuditTest / MatchmakerWriteDenyUniformityTest - 验证: mvn verify 884/884(3 skip) + UI 契约 24/24 + 后端契约门禁 198 通过; 负向注入 5(工程师)+2(QA) 判红→还原复绿; QA fresh eyes 独立验收 PASS(0 P0/P1, 2 P2 观察项移交 ZC-MM-007) - ContactAuthorizationPolicy 维持 DEFERRED; C 端死代码退役评估 3 项留待 007/专项治理
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -101,6 +101,9 @@ public class OperatorAuthService {
|
||||
.toList();
|
||||
|
||||
// 签发 Token
|
||||
// ZC-MM-006 / T02 声明:claims 中的 permissions 仅为登录时刻的展示快照(只写不读),
|
||||
// **不是授权事实源** —— 服务端每请求实时查库(JwtAuthFilter → PermissionMapper),
|
||||
// 权限回收/角色调整/账号禁用即时生效,无需等待 token 过期或撤销名单。
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("operatorId", operator.getId());
|
||||
claims.put("organizationId", operator.getOrganizationId());
|
||||
|
||||
+22
-7
@@ -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 一致。
|
||||
* <b>NULL=NULL 视为平台直属</b>:平台直属组长只能分配平台直属红娘;
|
||||
* 有组织组长不能分配 org=NULL 红娘。违规走 DataScopeGuard.deny(审计 + 403)。
|
||||
* 有组织组长不能分配 org=NULL 红娘。违规统一先落 DATA_SCOPE_DENIED 审计。
|
||||
*
|
||||
* <p>拒绝口径(ZC-MM-006 / T04):
|
||||
* <ul>
|
||||
* <li>{@code notFoundOnDeny=false}(create/assign 类,无资源寻址)→ 403 AccessDeniedException;</li>
|
||||
* <li>{@code notFoundOnDeny=true}(transfer/revoke/end,资源寻址写端点)→ 404 化
|
||||
* {@link DataScopeGuard#denyAsNotFound}(响应与"资源不存在"逐字段一致,防探测)。</li>
|
||||
* </ul>
|
||||
*/
|
||||
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。 */
|
||||
|
||||
+55
-13
@@ -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<String, Object> 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<String, Object> 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 化)。
|
||||
*
|
||||
* <p><b>无任何回退</b>: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);
|
||||
}
|
||||
}
|
||||
|
||||
+8
-22
@@ -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 枚举探测。
|
||||
*
|
||||
* <p>仅用于读详情路径;写接口维持 {@link #assertActiveAssignment} 的 403 现状。
|
||||
* <p>适用面(ZC-MM-006 / T04 扩展):读详情 + <b>带资源寻址的写端点</b>
|
||||
* (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 口径),读前惰性过期。 */
|
||||
|
||||
+10
-6
@@ -41,18 +41,22 @@ public class ReferralService {
|
||||
*
|
||||
* 修复后:先断言存在生效中的分配关系,否则拒绝。这样工作人员只能在真实的红娘服务关系内转介。
|
||||
*
|
||||
* <p>ZC-MM-005 收口(P1-3):授权事实源由 matchmaker_services.assigned_operator_id 残留列
|
||||
* ({@code findActiveByOperatorAndUser},按服务单 status 判定)切换为
|
||||
* {@link com.danyang.match.platform.matchmaker.service.ServiceAssignmentPolicy#canAccessUser}
|
||||
* <p>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)。
|
||||
*
|
||||
* <p>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
|
||||
|
||||
@@ -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 工作台查看服务对象详情";
|
||||
}
|
||||
@@ -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<String> 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<String> permissions = operatorToken
|
||||
? permissionService.getPermissions(subject)
|
||||
: List.of();
|
||||
|
||||
+3
@@ -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) {
|
||||
|
||||
+75
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ZC-MM-006 / T04(写接口 404 统一·防探测):范围校验失败的写端点 ——
|
||||
* 先落与 {@link #deny} 完全相同的 DATA_SCOPE_DENIED 审计(语义上仍是 403 越权),
|
||||
* 再抛 {@code BusinessException(NOT_FOUND, "服务不存在")}:响应与"资源不存在"逐字段一致,
|
||||
* 调用方无法区分"无权"与"不存在",杜绝按资源 ID 枚举探测写端点。
|
||||
*
|
||||
* <p>适用面:带资源寻址的写端点(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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
+3
-1
@@ -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/";
|
||||
|
||||
+12
-3
@@ -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);
|
||||
}
|
||||
|
||||
+13
-6
@@ -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 审计");
|
||||
|
||||
|
||||
+203
@@ -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)写入链路。
|
||||
*
|
||||
* <p>覆盖:
|
||||
* <ul>
|
||||
* <li>扩展写入(正向):assign / recommend 审计行落 purpose_code + service_assignment_id;</li>
|
||||
* <li>兼容性(负向·缺省调用):applyService 走旧签名(purposeCode/assignmentId 缺省)仍可写,
|
||||
* purpose_code 为 null —— 证明扩展列全可空、存量行为不变;</li>
|
||||
* <li>拒绝路径(负向):无分配 recommend → AccessDeniedException + DATA_SCOPE_DENIED 审计,
|
||||
* 且审计行带 actor_role_snapshot(SecurityContext 权限快照)+ request_trace_id(MDC)。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@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<String> 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<AuditLog> rows = auditLogMapper.selectList(new LambdaQueryWrapper<AuditLog>()
|
||||
.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)");
|
||||
}
|
||||
}
|
||||
+9
-6
@@ -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<String, Object> 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));
|
||||
}
|
||||
|
||||
|
||||
+11
-10
@@ -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(),
|
||||
"越权推荐失败后不得留下任何推荐记录");
|
||||
|
||||
+181
@@ -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 接入红娘服务对象详情。
|
||||
*
|
||||
* <p>语义(AGENTS.md #8:敏感数据默认拒绝,查看动作本身必须可溯源):
|
||||
* <ul>
|
||||
* <li>持有人读 GET /operator/matchmaker/services/{id}(getServiceDetail)→
|
||||
* sensitive_access_logs 落 success 行(dataType=matchmaker_service_detail);</li>
|
||||
* <li>拒绝路径维持 audit_logs DATA_SCOPE_DENIED(判定器写入),<b>不重复写</b>
|
||||
* SensitiveAccessLog —— 双记录互斥;</li>
|
||||
* <li>/my 与 /team/statistics 不接入敏感访问日志(无服务对象敏感明细)。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>覆盖:正例 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<SensitiveAccessLog> accessRows(String operatorId, String serviceId) {
|
||||
return sensitiveAccessLogMapper.selectList(new LambdaQueryWrapper<SensitiveAccessLog>()
|
||||
.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<String, Object> detail = matchmakerServiceService.getServiceDetail(serviceId, holderId);
|
||||
assertNotNull(detail);
|
||||
|
||||
List<SensitiveAccessLog> 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<AuditLog>()
|
||||
.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(),
|
||||
"查无资源不构成敏感访问,不得落行");
|
||||
}
|
||||
}
|
||||
+284
@@ -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 统一(防探测)。
|
||||
*
|
||||
* <p>语义:带资源寻址的写端点(transfer / revoke / end / recommend / followup / complete /
|
||||
* 转介)范围校验失败时,先落 DATA_SCOPE_DENIED 审计,再抛 404「服务不存在」——
|
||||
* 响应与"资源不存在"(读详情 404)逐字段一致,调用方无法区分"无权"与"不存在",
|
||||
* 杜绝按资源 ID 枚举探测写端点。create / assign 类无资源寻址端点维持 403(不在本测试面)。
|
||||
*
|
||||
* <p>覆盖:负向×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<AuditLog>()
|
||||
.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<String, Object> 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<BusinessException> 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());
|
||||
}
|
||||
}
|
||||
+112
@@ -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/** 归属。
|
||||
*
|
||||
* <p>防护场景:运营端控制器普遍用类级 @RequestMapping("/operator/xxx") + 方法级
|
||||
* @GetMapping("/yyy") —— 单看方法路径不含 /operator 前缀。若后续把路径从类级挪到
|
||||
* 方法级(或新增控制器时前缀写错),本用例通过「类前缀 + 方法路径」合成完整路径逐条
|
||||
* 判定,保证归属判定不因 mapping 写法迁移而漂移。
|
||||
*/
|
||||
@Test
|
||||
void classLevelPrefixMustComposeIntoFullOperatorPath() {
|
||||
int composedCount = 0;
|
||||
List<String> violations = new ArrayList<>();
|
||||
for (Class<?> controller : CONTROLLERS) {
|
||||
List<String> classPaths = paths(controller);
|
||||
if (classPaths.isEmpty() || classPaths.stream().noneMatch(this::isOperatorPath)) {
|
||||
continue;
|
||||
}
|
||||
for (Method method : controller.getDeclaredMethods()) {
|
||||
List<String> 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 的控制器,其运营端接口
|
||||
* 必须逐方法显式授权(未全覆盖 → 红)。
|
||||
*
|
||||
* <p>防护场景:带类级 @PreAuthorize 的控制器(如 MatchmakerOperatorController)允许
|
||||
* 个别方法不重复标注;但若有人日后删掉类级注解,这些"裸"方法即失去权限粒度 ——
|
||||
* 本用例在"类级已缺失"的状态下强制逐方法覆盖,删注解动作立即变红。
|
||||
*/
|
||||
@Test
|
||||
void controllerWithoutClassLevelPreAuthorizeMustCoverEveryOperatorEndpoint() {
|
||||
List<String> violations = new ArrayList<>();
|
||||
for (Class<?> controller : CONTROLLERS) {
|
||||
boolean classLevel = AnnotatedElementUtils.hasAnnotation(controller, PreAuthorize.class);
|
||||
for (Method method : controller.getDeclaredMethods()) {
|
||||
List<String> 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。
|
||||
*
|
||||
* <p>背景(ZC-MM-003/004 治理口径):红娘域全部状态迁移走显式语义端点
|
||||
* (accept/pass/confirm/transfer/revoke/end 等),每个动作独立审计与状态机校验。
|
||||
* PATCH 部分更新语义会绕过「动作 → 状态机 + 审计」链路,出现即视为架构违规。
|
||||
*/
|
||||
@Test
|
||||
void matchmakerAndReferralPackagesMustNotUsePatchMapping() {
|
||||
List<String> 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<String> paths(java.lang.reflect.AnnotatedElement element) {
|
||||
RequestMapping mapping = AnnotatedElementUtils.findMergedAnnotation(element, RequestMapping.class);
|
||||
return mapping == null ? List.of() : Arrays.asList(mapping.path().length > 0
|
||||
|
||||
+198
@@ -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 权限回收(实时查库路线,不建撤销名单)。
|
||||
*
|
||||
* <p>语义:operator token 每请求实时查库 —— ① operators.status 非 active(含账号删除)
|
||||
* → 401、不建 principal;② 权限由 PermissionMapper join role_permissions/operator_roles
|
||||
* 实时装载,DB 撤销权限后已签发 token 立即失去对应能力(403 + AUTHORIZATION_DENIED 审计)。
|
||||
* token claims 中的 permissions 仅为登录时刻快照,不是授权事实源(只写不读)。
|
||||
*
|
||||
* <p>覆盖:
|
||||
* <ul>
|
||||
* <li>正例:DB 授权(role_permissions)→ token 调 dashboard 200;</li>
|
||||
* <li>负向①:DB UPDATE status='disabled' → 同一 token → 401;</li>
|
||||
* <li>负向②:DB 删 role_permissions 行 → 同一 token 调 recommend → 403
|
||||
* + AUTHORIZATION_DENIED 审计(含 actor_role_snapshot 权限快照);</li>
|
||||
* <li>负向③(回归):用户 token 打 operator 路径 → 403(USER fallback 语义不回归)。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@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<AuditLog>()
|
||||
.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<AuditLog>()
|
||||
.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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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 白名单。
|
||||
|
||||
Reference in New Issue
Block a user