diff --git a/backend/src/main/java/com/danyang/match/platform/matchmaker/config/ContactAuthorizationConfig.java b/backend/src/main/java/com/danyang/match/platform/matchmaker/config/ContactAuthorizationConfig.java index a8d89f5..2609df1 100644 --- a/backend/src/main/java/com/danyang/match/platform/matchmaker/config/ContactAuthorizationConfig.java +++ b/backend/src/main/java/com/danyang/match/platform/matchmaker/config/ContactAuthorizationConfig.java @@ -29,10 +29,20 @@ public class ContactAuthorizationConfig { private boolean enabled = false; /** - * 审批岗权限码(逗号分隔,任一命中即可审批)。 - * 默认 "matchmaker.team.assign,matchmaker.assign"(组长组织内审批 / 平台授权岗位全量审批)。 - * 审批权限在 Service 层动态判定(principal 权限 ∈ 本集合),不用 @PreAuthorize 静态注解 —— - * 审批岗名单可经 yml 调整而无需改代码。 + * 审批岗权限码(逗号分隔,分层语义,用户裁决①:组长主审 + 管理员兜底)。 + * 默认 "matchmaker.team.assign,matchmaker.assign"(键名与默认值不变)。 + * + *

分层规则(ContactAuthorizationService.assertApproverAuthority): + *

    + *
  1. 列表首段 = 主审岗(默认 {@code matchmaker.team.assign},组长)—— + * 仅可审批本组织红娘的申请(NULL=NULL 平台直属,assertTeamScope 复用);
  2. + *
  3. 兜底岗键固定 {@code matchmaker.assign}(平台管理员)—— + * 可审批全部申请且不做组织校验;兜底键不受本列表调整影响(权限面不放大);
  4. + *
  5. 两者皆无 → 403 拒绝。
  6. + *
+ * + *

审批权限在 Service 层动态判定(principal 权限 ∈ 分层规则),不用 @PreAuthorize + * 静态注解 —— 主审岗名单可经 yml 调整而无需改代码。 */ private String approverAuthorities = "matchmaker.team.assign,matchmaker.assign"; diff --git a/backend/src/main/java/com/danyang/match/platform/matchmaker/service/ContactAuthorizationService.java b/backend/src/main/java/com/danyang/match/platform/matchmaker/service/ContactAuthorizationService.java index 488c757..022a8f6 100644 --- a/backend/src/main/java/com/danyang/match/platform/matchmaker/service/ContactAuthorizationService.java +++ b/backend/src/main/java/com/danyang/match/platform/matchmaker/service/ContactAuthorizationService.java @@ -34,10 +34,11 @@ import java.util.stream.Collectors; * *

六操作状态机(全矩阵): *

- *   request  : 红娘(须持分配)→ pending_approval,审计含 service_assignment_id
- *   approve  : 审批岗(权限码动态判定 ∈ approver-authorities + 组长组织范围)
- *              pending_approval → pending_consent;违规 403(无资源枚举面维持 403)
- *   reject   : 审批岗拒绝 pending_approval → rejected
+ *   request  : 红娘(须持分配 + 机制开启)→ pending_approval,审计含 service_assignment_id
+ *   approve  : 审批岗分层判定(用户裁决①:组长主审 + 管理员兜底,见
+ *              {@link #assertApproverAuthority})pending_approval → pending_consent;
+ *              违规 403(无资源枚举面维持 403)
+ *   reject   : 审批岗(分层同 approve)拒绝 pending_approval → rejected
  *   consent  : C 端用户本人(归属校验,独立端点、无预同意参数)
  *              pending_consent → active(expires_at = consent + default-validity-days)
  *   decline  : C 端用户本人 pending_consent → rejected
@@ -59,6 +60,11 @@ public class ContactAuthorizationService {
     private static final String RESOURCE_TYPE = "matchmaker_contact_authorization";
     /** 披露敏感访问的数据类型(SensitiveAccessLog.data_type) */
     public static final String SENSITIVE_DATA_TYPE_PHONE_DISCLOSURE = "user_phone_disclosure";
+    /**
+     * 兜底审批岗权限键(用户裁决① 固定):持有者可审批全部申请且不做组织校验。
+     * 主审岗权限键仍由 approver-authorities 列表首段配置(默认 matchmaker.team.assign)。
+     */
+    private static final String FALLBACK_APPROVER_AUTHORITY = "matchmaker.assign";
 
     private final MatchmakerContactAuthorizationMapper authorizationMapper;
     private final ServiceAssignmentPolicy serviceAssignmentPolicy;
@@ -75,6 +81,12 @@ public class ContactAuthorizationService {
     @Transactional
     public MatchmakerContactAuthorization request(String operatorId, String userId,
                                                    String channel, String purpose) {
+        // fail-safe 最先(与 disclose 同口径):机制关闭(配置缺失/显式关闭)→ 404 化拒绝。
+        // ZC-MM-003 裁决落地 T-C:request 原实现漏查 enabled(真实缺口,本轮补齐)。
+        if (!config.isEnabled()) {
+            dataScopeGuard.denyAsNotFound(operatorId, RESOURCE_TYPE, userId,
+                    "服务不存在", null, null);
+        }
         if (purpose == null || purpose.isBlank()) {
             throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "授权目的必填");
         }
@@ -114,17 +126,28 @@ public class ContactAuthorizationService {
         return auth;
     }
 
-    // ==================== ② approve / ③ reject:审批岗 ====================
+    // ==================== ② approve / ③ reject:审批岗(分层) ====================
 
-    /** 审批岗权限码动态判定(principal 权限 ∈ approver-authorities 配置,非 @PreAuthorize 静态)。 */
+    /**
+     * 审批岗分层判定(用户裁决①:组长主审 + 管理员兜底)。
+     *
+     * 

分层规则: + *

    + *
  1. 持 {@code matchmaker.assign}(兜底岗,平台管理员)→ 可审批全部申请, + * 不做组织校验(见 {@link #assertOrgScopeForApprover});
  2. + *
  3. 否则持主审岗权限键(approver-authorities 列表首段,默认 + * {@code matchmaker.team.assign},组长)→ 主审,须通过 + * {@code assertTeamScope} 组织范围校验(NULL=NULL 平台直属,现状维持);
  4. + *
  5. 两者皆无 → 403 deny(不变)。
  6. + *
+ * + *

主审岗键仍由配置驱动(列表首段),兜底岗键固定为 {@code matchmaker.assign} + * —— 配置只能收紧/调整主审岗,不能扩大兜底岗权限面。 + */ private void assertApproverAuthority(String approverOperatorId, String authId, Set principalPermissions) { - Set approverAuthorities = Arrays.stream(config.getApproverAuthorities().split(",")) - .map(String::trim) - .filter(s -> !s.isEmpty()) - .collect(Collectors.toSet()); - boolean authorized = principalPermissions != null - && principalPermissions.stream().anyMatch(approverAuthorities::contains); + boolean authorized = isFallbackApprover(principalPermissions) + || isPrimaryApprover(principalPermissions); if (!authorized) { // 非审批岗 → 403(approve 无资源枚举面,维持 403;边界登记 T06 OQ-MATCHMAKER-009) dataScopeGuard.deny(approverOperatorId, RESOURCE_TYPE, authId, @@ -132,6 +155,35 @@ public class ContactAuthorizationService { } } + /** 兜底岗判定:持固定键 matchmaker.assign(平台管理员)→ 全量审批、无组织校验。 */ + private boolean isFallbackApprover(Set principalPermissions) { + return principalPermissions != null && principalPermissions.contains(FALLBACK_APPROVER_AUTHORITY); + } + + /** 主审岗判定:principal 权限 ∈ approver-authorities 任一(默认首段 team.assign 组长)。 */ + private boolean isPrimaryApprover(Set principalPermissions) { + Set approverAuthorities = Arrays.stream(config.getApproverAuthorities().split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toSet()); + return principalPermissions != null + && principalPermissions.stream().anyMatch(approverAuthorities::contains); + } + + /** + * 分层组织范围校验(用户裁决①):兜底岗(持 matchmaker.assign)跳过组织校验; + * 主审岗(组长)固定走 {@code assertTeamScope}(hasGlobalAssign=false —— + * 是否兜底由本层判定,不再向底层传组织豁免,防止权限面双通道扩大)。 + */ + private void assertOrgScopeForApprover(String approverOperatorId, String authId, + Set principalPermissions, String targetOperatorId) { + if (isFallbackApprover(principalPermissions)) { + return; + } + matchmakerAssignmentService.assertTeamScope(approverOperatorId, false, + targetOperatorId, authId); + } + @Transactional public MatchmakerContactAuthorization approve(String authId, String approverOperatorId, Set principalPermissions) { @@ -141,11 +193,9 @@ public class ContactAuthorizationService { throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态(" + auth.getStatus() + ")不允许审批"); } - // 组长组织范围校验(002 assertAssignScope 复用):matchmaker.assign 持有者不限组织 - boolean hasGlobalAssign = principalPermissions != null - && principalPermissions.contains("matchmaker.assign"); - matchmakerAssignmentService.assertTeamScope(approverOperatorId, hasGlobalAssign, - auth.getOperatorId(), authId); + // 分层组织范围校验(用户裁决①):管理员兜底全量 / 组长主审限同组织(NULL=NULL 平台直属) + assertOrgScopeForApprover(approverOperatorId, authId, principalPermissions, + auth.getOperatorId()); auth.setApprovedBy(approverOperatorId); auth.setApprovedAt(Instant.now()); @@ -170,10 +220,9 @@ public class ContactAuthorizationService { throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态(" + auth.getStatus() + ")不允许拒绝"); } - boolean hasGlobalAssign = principalPermissions != null - && principalPermissions.contains("matchmaker.assign"); - matchmakerAssignmentService.assertTeamScope(approverOperatorId, hasGlobalAssign, - auth.getOperatorId(), authId); + // 分层组织范围校验(用户裁决①,与 approve 同口径) + assertOrgScopeForApprover(approverOperatorId, authId, principalPermissions, + auth.getOperatorId()); auth.setStatus(Constants.CONTACT_AUTH_STATUS_REJECTED); authorizationMapper.updateById(auth); diff --git a/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerContactAuthorizationTest.java b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerContactAuthorizationTest.java index eabf51e..a549761 100644 --- a/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerContactAuthorizationTest.java +++ b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerContactAuthorizationTest.java @@ -79,6 +79,9 @@ class MatchmakerContactAuthorizationTest { private static final String ORG_Y = "55555555-5555-5555-5555-555555555552"; private static final Set P_LEADER = Set.of("matchmaker.view", "matchmaker.team.assign"); private static final Set P_PLATFORM = Set.of("matchmaker.view", "matchmaker.assign"); + /** 双岗:team.assign(主审)+ assign(兜底)——按分层规则含 assign 即兜底全量 */ + private static final Set P_DUAL = + Set.of("matchmaker.view", "matchmaker.team.assign", "matchmaker.assign"); @BeforeEach void setUp() { @@ -266,13 +269,85 @@ class MatchmakerContactAuthorizationTest { } @Test - @DisplayName("B1 负向③: 跨组织组长 approve → 403(组织范围校验)") + @DisplayName("B1 负向③: 仅 team.assign 组长审他组织 → 403 + DATA_SCOPE_DENIED 审计(分层·主审岗)") void crossOrgLeaderCannotApprove() { MatchmakerContactAuthorization auth = contactAuthorizationService.request( mmOwner, user, Constants.CONTACT_CHANNEL_PHONE, "电话沟通"); + long before = deniedAudits(leaderY, auth.getId()); assertThrows(AccessDeniedException.class, () -> contactAuthorizationService.approve(auth.getId(), leaderY, P_LEADER), - "跨组织组长不得审批"); + "跨组织组长(仅主审岗)不得审批"); + assertEquals(before + 1, deniedAudits(leaderY, auth.getId()), + "跨组织组长审批拒绝必须落 DATA_SCOPE_DENIED 审计"); + } + + // ==================== 分层审批语义(用户裁决①:组长主审 + 管理员兜底) ==================== + + @Test + @DisplayName("分层正例①: 仅 assign 管理员(兜底岗)审批跨组织申请 → 成功 + 审计行") + void platformFallbackApproverCanApproveCrossOrg() { + // 管理员挂任意组织(org Y)——兜底岗不做组织校验 + String admin = createOperator("联系授权平台管理员", ORG_Y); + MatchmakerContactAuthorization auth = contactAuthorizationService.request( + mmOwner, user, Constants.CONTACT_CHANNEL_PHONE, "跨组织管理员兜底审批"); + MatchmakerContactAuthorization approved = contactAuthorizationService.approve( + auth.getId(), admin, P_PLATFORM); + // 结果码断言:approve 成功 → pending_consent + assertEquals(Constants.CONTACT_AUTH_STATUS_PENDING_CONSENT, approved.getStatus()); + assertEquals(admin, approved.getApprovedBy()); + // 审计行断言:approve 审计行存在且 purpose_code 正确、操作者=管理员 + AuditLog audit = latestContactAudit(Constants.CONTACT_AUDIT_ACTION_APPROVE, auth.getId()); + assertNotNull(audit, "兜底审批成功必须落 MATCHMAKER_CONTACT_APPROVE 审计行"); + assertEquals("matchmaker.contact.approve", audit.getPurposeCode()); + assertEquals(admin, audit.getOperatorId()); + } + + @Test + @DisplayName("分层正例②: 平台直属组长(org NULL)审平台直属红娘申请 → 成功 + 审计行") + void platformDirectLeaderCanApproveNullOrgMatchmaker() { + // 平台直属链路:org=NULL 组长 + org=NULL 红娘(NULL=NULL 视为平台直属,现状⑤维持) + String leaderN = createOperator("平台直属组长", null); + String mmNull = createOperator("平台直属红娘", null); + String userNull = createUser("13600009998"); + MatchmakerServiceRecord svc = new MatchmakerServiceRecord(); + svc.setUserId(userNull); + svc.setServiceType(Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER); + svc.setDescription("平台直属联系授权测试服务单"); + svc.setStatus(Constants.MATCHMAKER_STATUS_SUBMITTED); + svc.setAssignedOperatorId(mmNull); + serviceMapper.insert(svc); + // 主审组长(hasGlobalAssign=false)走 NULL=NULL 组织校验创建分配 + assignmentService.create(leaderN, false, userNull, mmNull, svc.getId(), + "平台直属分配", null); + + MatchmakerContactAuthorization auth = contactAuthorizationService.request( + mmNull, userNull, Constants.CONTACT_CHANNEL_PHONE, "平台直属电话沟通"); + MatchmakerContactAuthorization approved = contactAuthorizationService.approve( + auth.getId(), leaderN, P_LEADER); + assertEquals(Constants.CONTACT_AUTH_STATUS_PENDING_CONSENT, approved.getStatus()); + assertEquals(leaderN, approved.getApprovedBy()); + AuditLog audit = latestContactAudit(Constants.CONTACT_AUDIT_ACTION_APPROVE, auth.getId()); + assertNotNull(audit, "平台直属组长审批成功必须落 MATCHMAKER_CONTACT_APPROVE 审计行"); + assertEquals("matchmaker.contact.approve", audit.getPurposeCode()); + assertEquals(leaderN, audit.getOperatorId()); + } + + @Test + @DisplayName("分层正例③: 双岗(team.assign+assign)审他组织 → 含 assign 即兜底全量 + 审计行") + void dualRoleApproverFallsBackToFullScope() { + // 用户裁决①分层规则的既定语义:兜底岗键固定 matchmaker.assign, + // 双岗持有者按兜底全量处理(不做组织校验)——权限面以最高档收敛,不叠加受限。 + String dual = createOperator("双岗审批人", ORG_Y); + MatchmakerContactAuthorization auth = contactAuthorizationService.request( + mmOwner, user, Constants.CONTACT_CHANNEL_PHONE, "双岗跨组织审批"); + MatchmakerContactAuthorization approved = contactAuthorizationService.approve( + auth.getId(), dual, P_DUAL); + assertEquals(Constants.CONTACT_AUTH_STATUS_PENDING_CONSENT, approved.getStatus()); + assertEquals(dual, approved.getApprovedBy()); + AuditLog audit = latestContactAudit(Constants.CONTACT_AUDIT_ACTION_APPROVE, auth.getId()); + assertNotNull(audit, "双岗兜底审批成功必须落 MATCHMAKER_CONTACT_APPROVE 审计行"); + assertEquals("matchmaker.contact.approve", audit.getPurposeCode()); + assertEquals(dual, audit.getOperatorId()); } @Test diff --git a/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerContactDisabledTest.java b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerContactDisabledTest.java new file mode 100644 index 0000000..94509a3 --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerContactDisabledTest.java @@ -0,0 +1,104 @@ +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.MatchmakerContactAuthorization; +import com.danyang.match.platform.matchmaker.mapper.MatchmakerContactAuthorizationMapper; +import com.danyang.match.platform.matchmaker.service.ContactAuthorizationService; +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 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.test.context.TestPropertySource; +import org.springframework.transaction.annotation.Transactional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * OQ-010 验收:机制总开关关闭(matchmaker.contact.enabled=false)时的 fail-safe 行为。 + * + *

fail-safe 语义(ContactAuthorizationConfig.enabled Java 缺省 false,配置缺失=机制关闭): + *

+ */ +@SpringBootTest(properties = "matchmaker.contact.enabled=false") +@ActiveProfiles("test") +@Transactional +class MatchmakerContactDisabledTest { + + private static final String OPERATOR_ID = "disabled-test-operator-0001"; + private static final String USER_ID = "disabled-test-user-0001"; + + @Autowired private ContactAuthorizationService contactAuthorizationService; + @Autowired private MatchmakerContactAuthorizationMapper authorizationMapper; + @Autowired private AuditLogMapper auditLogMapper; + @Autowired private SensitiveAccessLogMapper sensitiveAccessLogMapper; + + @Test + @DisplayName("开关关闭: disclose → 404 化拒绝,不落 SensitiveAccessLog / 成功审计行") + void discloseRejectedWhenDisabled() { + BusinessException e = assertThrows(BusinessException.class, + () -> contactAuthorizationService.disclose(OPERATOR_ID, USER_ID, + Constants.CONTACT_CHANNEL_PHONE, "机制关闭后披露"), + "机制关闭时披露必须 fail-safe 拒绝"); + assertEquals(ApiResponseCode.NOT_FOUND, e.getCode(), "fail-safe 必须 404 化(G4 防探测)"); + assertEquals("服务不存在", e.getMessage()); + + // 不落 SensitiveAccessLog(成功行) + Long sensitiveCount = sensitiveAccessLogMapper.selectCount(new LambdaQueryWrapper() + .eq(SensitiveAccessLog::getOperatorId, OPERATOR_ID) + .eq(SensitiveAccessLog::getDataType, + ContactAuthorizationService.SENSITIVE_DATA_TYPE_PHONE_DISCLOSURE)); + assertEquals(0L, sensitiveCount, "机制关闭的拒绝披露不得落 SensitiveAccessLog"); + + // 不落 matchmaker.contact.disclose 成功审计行(DATA_SCOPE_DENIED deny 审计可有) + Long discloseAuditCount = auditLogMapper.selectCount(new LambdaQueryWrapper() + .eq(AuditLog::getAction, Constants.CONTACT_AUDIT_ACTION_DISCLOSE) + .eq(AuditLog::getOperatorId, OPERATOR_ID)); + assertEquals(0L, discloseAuditCount, "机制关闭的拒绝披露不得落成功审计行"); + } + + @Test + @DisplayName("开关关闭: request → 404 化拒绝(T-C 补齐真实缺口),不落任何成功审计行") + void requestRejectedWhenDisabled() { + BusinessException e = assertThrows(BusinessException.class, + () -> contactAuthorizationService.request(OPERATOR_ID, USER_ID, + Constants.CONTACT_CHANNEL_PHONE, "机制关闭后申请"), + "机制关闭时申请必须 fail-safe 拒绝"); + assertEquals(ApiResponseCode.NOT_FOUND, e.getCode(), "fail-safe 必须 404 化(与 disclose 同口径)"); + assertEquals("服务不存在", e.getMessage()); + + // 不产生任何授权记录(fail-safe 校验先于一切业务逻辑) + Long authCount = authorizationMapper.selectCount(new LambdaQueryWrapper() + .eq(MatchmakerContactAuthorization::getOperatorId, OPERATOR_ID)); + assertEquals(0L, authCount, "机制关闭的拒绝申请不得产生授权记录"); + + // 不落 matchmaker.contact.request 成功审计行(DATA_SCOPE_DENIED deny 审计可有) + Long requestAuditCount = auditLogMapper.selectCount(new LambdaQueryWrapper() + .eq(AuditLog::getAction, Constants.CONTACT_AUDIT_ACTION_REQUEST) + .eq(AuditLog::getOperatorId, OPERATOR_ID)); + assertEquals(0L, requestAuditCount, "机制关闭的拒绝申请不得落成功审计行"); + + // fail-safe 必须先于 purpose/channel 业务校验(缺目的的申请同样 404 化拒绝) + BusinessException e2 = assertThrows(BusinessException.class, + () -> contactAuthorizationService.request(OPERATOR_ID, USER_ID, + Constants.CONTACT_CHANNEL_PHONE, null)); + assertEquals(ApiResponseCode.NOT_FOUND, e2.getCode(), + "fail-safe 必须最先判定,不得让业务校验异常(400/422)穿透"); + assertNotNull(e2.getMessage()); + } +}