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);
|
||||
Reference in New Issue
Block a user