From ef61d51f6135ae3750756b5d88a95787ebe99b3a Mon Sep 17 00:00:00 2001 From: WorkBuddy Date: Mon, 12 Oct 2026 10:15:46 +0800 Subject: [PATCH] =?UTF-8?q?fix(audit,matchmaker):=20V057=20audit=5Flogs=20?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20subject=5Fuser=5Fid=20=E4=B8=BB=E4=BD=93?= =?UTF-8?q?=E7=BB=B4=E5=BA=A6=EF=BC=88=E8=BF=90=E8=A1=8C=E6=97=B6=E5=AE=9E?= =?UTF-8?q?=E6=B5=8B=E6=8A=93=E5=88=B0=20C=20=E7=AB=AF=E5=AE=A1=E8=AE=A1?= =?UTF-8?q?=20FK=20=E7=A1=AC=E7=BC=BA=E9=99=B7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 根因:audit_logs.operator_id FK→operators,C 端用户(consent/decline/越权)触发的审计把 userId 写 operator_id → FK violation:consent 成功路径 500 回滚(阻塞主链路)、deny 路径写失败被吞(C 端越权零审计,违反 DEC-006);H2 无 FK → 集成测试假绿(测试库缺约束类假绿第二例) - V057:audit_logs + subject_user_id UUID FK users(可空)+ idx_audit_logs_subject_user;主体语义=operator_id 与 subject_user_id 恰有一者非空 - AuditLog 实体 +subjectUserId;DataScopeGuard.denyUser(subject 维度 deny,先审计后 403);AuditLogService.recordUserAction 备用入口 - ContactAuthorizationService:consent/decline 改 saveUserAudit(subject 维度);requireOwnedAuthorization 改 denyUser;operator 侧 6 处不变 - 测试:生命周期断言主体分流(consent 行 subject_user_id/operator_id=null/无 assignment_id);非本人 consent 负例补「越权 deny 审计必须落 subject 维度」断言;LATEST_MIGRATION 56→57 - 验证:定向 33/33(授权 13+disabled 2+守卫 5+迁移 13);负向注入 denyUser 写回 operator 维度 → 恰好新断言判红 → 还原复绿 - 发现渠道:本地栈运行时实测(mock 登录新用户 consent 403 → deny 审计 FK 失败日志溯源) --- .../match/platform/audit/entity/AuditLog.java | 5 +++ .../audit/service/AuditLogService.java | 25 ++++++++++++++ .../service/ContactAuthorizationService.java | 33 +++++++++++++++---- .../shared/security/DataScopeGuard.java | 24 ++++++++++++++ .../V057__audit_logs_subject_user.sql | 17 ++++++++++ .../deployment/MigrationValidationTest.java | 2 +- .../MatchmakerContactAuthorizationTest.java | 23 +++++++++++-- backend/src/test/resources/schema-h2.sql | 4 ++- 8 files changed, 122 insertions(+), 11 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V057__audit_logs_subject_user.sql diff --git a/backend/src/main/java/com/danyang/match/platform/audit/entity/AuditLog.java b/backend/src/main/java/com/danyang/match/platform/audit/entity/AuditLog.java index b41b146..07d56b9 100644 --- a/backend/src/main/java/com/danyang/match/platform/audit/entity/AuditLog.java +++ b/backend/src/main/java/com/danyang/match/platform/audit/entity/AuditLog.java @@ -30,4 +30,9 @@ public class AuditLog extends BaseEntity { private String actorRoleSnapshot; /** 请求追踪 ID(RequestIdFilter 写入 MDC 的 requestId)。 */ private String requestTraceId; + + // ==================== V057 / 运行时实测修复:C 端用户主体维度 ==================== + + /** 用户主体 ID(C 端触发的审计写此列且 operator_id=null;两者恰有一者非空)。 */ + private String subjectUserId; } diff --git a/backend/src/main/java/com/danyang/match/platform/audit/service/AuditLogService.java b/backend/src/main/java/com/danyang/match/platform/audit/service/AuditLogService.java index 02233eb..b74df98 100644 --- a/backend/src/main/java/com/danyang/match/platform/audit/service/AuditLogService.java +++ b/backend/src/main/java/com/danyang/match/platform/audit/service/AuditLogService.java @@ -103,4 +103,29 @@ public class AuditLogService { } auditLogMapper.insert(auditLog); } + + /** + * V057 / 运行时实测修复:C 端用户主体的审计入口。 + * operator 主体照旧走 recordSensitiveAccess/saveAuditLog(operator_id 维度); + * user 主体(consent/decline 等用户动作)写 subject_user_id 且 operator_id=null—— + * 否则 userId 撞 audit_logs_operator_id_fkey(→operators) 必 FK violation(H2 无 FK 假绿)。 + */ + @Transactional + public void recordUserAction(String userId, String action, String resourceType, String resourceId, + String purposeCode, String afterData) { + AuditLog auditLog = new AuditLog(); + auditLog.setSubjectUserId(userId); + auditLog.setAction(action); + auditLog.setResourceType(resourceType); + auditLog.setResourceId(resourceId); + auditLog.setAfterData(afterData); + auditLog.setPurposeCode(purposeCode); + if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attrs) { + HttpServletRequest req = attrs.getRequest(); + auditLog.setIp(req.getRemoteAddr()); + String ua = req.getHeader("User-Agent"); + auditLog.setUserAgent(ua != null ? (ua.length() > 512 ? ua.substring(0, 512) : ua) : null); + } + auditLogMapper.insert(auditLog); + } } 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 252cbc6..32e8caf 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 @@ -255,9 +255,8 @@ public class ContactAuthorizationService { authorizationMapper.updateById(auth); log.info("受控联系授权用户同意: authId={}, userId={}", authId, userId); - saveAudit(userId, Constants.CONTACT_AUDIT_ACTION_CONSENT, authId, - activeAssignmentIdOf(auth), "matchmaker.contact.consent", - jsonOf("channel", auth.getChannel())); + saveUserAudit(userId, Constants.CONTACT_AUDIT_ACTION_CONSENT, authId, + "matchmaker.contact.consent", jsonOf("channel", auth.getChannel())); return auth; } @@ -272,9 +271,8 @@ public class ContactAuthorizationService { authorizationMapper.updateById(auth); log.info("受控联系授权用户拒绝: authId={}, userId={}", authId, userId); - saveAudit(userId, Constants.CONTACT_AUDIT_ACTION_DECLINE, authId, - activeAssignmentIdOf(auth), "matchmaker.contact.decline", - jsonOf("channel", auth.getChannel())); + saveUserAudit(userId, Constants.CONTACT_AUDIT_ACTION_DECLINE, authId, + "matchmaker.contact.decline", jsonOf("channel", auth.getChannel())); return auth; } @@ -424,7 +422,9 @@ public class ContactAuthorizationService { private MatchmakerContactAuthorization requireOwnedAuthorization(String authId, String userId) { MatchmakerContactAuthorization auth = requireAuthorization(authId); if (!auth.getUserId().equals(userId)) { - dataScopeGuard.deny(userId, RESOURCE_TYPE, authId, "仅本人可处理该授权请求"); + // V057:C 端主体走 denyUser(subject_user_id 维度)——userId 写 operator_id 维度 + // 必撞 audit_logs_operator_id_fkey(→operators),H2 无 FK 假绿、生产零审计 + dataScopeGuard.denyUser(userId, RESOURCE_TYPE, authId, "仅本人可处理该授权请求"); } return auth; } @@ -454,6 +454,25 @@ public class ContactAuthorizationService { auditLogMapper.insert(auditLog); } + /** + * V057 / 运行时实测修复:C 端用户主体的审计写入(consent/decline)。 + * subject_user_id 维度(operator_id=null)——userId 写 operator_id 必撞 + * audit_logs_operator_id_fkey(→operators):H2 无 FK 假绿,生产 FK violation 500。 + * service_assignment_id 不适用(用户动作不经分配),purpose_code 照旧落。 + */ + private void saveUserAudit(String userId, String action, String authId, + String purposeCode, String afterData) { + AuditLog auditLog = new AuditLog(); + auditLog.setSubjectUserId(userId); + auditLog.setAction(action); + auditLog.setResourceType(RESOURCE_TYPE); + auditLog.setResourceId(authId); + auditLog.setAfterData(afterData); + auditLog.setPurposeCode(purposeCode); + auditLog.setRequestTraceId(MDC.get("requestId")); + auditLogMapper.insert(auditLog); + } + /** * afterData 必须是合法 JSON(PG audit_logs.after_data 为 jsonb 严格解析; * H2 测试库 CLOB 不校验——裸 key=value 拼接在测试侧假绿、生产侧 500)。 diff --git a/backend/src/main/java/com/danyang/match/platform/shared/security/DataScopeGuard.java b/backend/src/main/java/com/danyang/match/platform/shared/security/DataScopeGuard.java index 8d3264c..6839d66 100644 --- a/backend/src/main/java/com/danyang/match/platform/shared/security/DataScopeGuard.java +++ b/backend/src/main/java/com/danyang/match/platform/shared/security/DataScopeGuard.java @@ -89,6 +89,30 @@ public class DataScopeGuard { throw new BusinessException(ApiResponseCode.NOT_FOUND, message); } + /** + * V057 / 运行时实测修复:C 端用户主体的数据范围拒绝。 + * 与 {@link #deny} 同「先审计后拒绝」语义,但审计主体写 subject_user_id + * (operator_id 置 null)——userId 写 operator_id 维度必撞 + * audit_logs_operator_id_fkey(→operators),H2 无 FK 假绿、生产零审计。 + */ + public void denyUser(String userId, String resourceType, String resourceId, String message) { + AuditLog audit = new AuditLog(); + audit.setId(UUID.randomUUID().toString()); + audit.setSubjectUserId(userId); + audit.setAction("DATA_SCOPE_DENIED"); + audit.setResourceType(resourceType); + audit.setResourceId(resourceId); + audit.setRequestTraceId(MDC.get("requestId")); + audit.setCreatedAt(Instant.now()); + try { + auditLogMapper.insert(audit); + } catch (RuntimeException exception) { + log.error("写入用户主体数据范围拒绝审计失败: subjectUserId={}, resourceType={}, resourceId={}", + userId, resourceType, resourceId, exception); + } + throw new AccessDeniedException(message); + } + /** * 当前操作者权限快照(与 {@link AuthorizationAuditListener} 同口径): * OperatorPrincipal.permissions() 逗号拼接,截断 128。 diff --git a/backend/src/main/resources/db/migration/V057__audit_logs_subject_user.sql b/backend/src/main/resources/db/migration/V057__audit_logs_subject_user.sql new file mode 100644 index 0000000..e6761b6 --- /dev/null +++ b/backend/src/main/resources/db/migration/V057__audit_logs_subject_user.sql @@ -0,0 +1,17 @@ +-- V057 (ZC-MM-003 B1 运行时实测修复): audit_logs 增加 C 端用户主体维度 +-- +-- 背景:运行时实测(本地栈真 PostgreSQL)发现 audit_logs.operator_id 存在 +-- FK → operators(id)(audit_logs_operator_id_fkey)。受控联系 C 端 +-- (/contact-authorizations/**)以「用户身份」触发的审计(consent/decline 成功、 +-- 越权 deny)把 userId 写进 operator_id → FK violation: +-- * consent/decline 成功路径:审计 insert 无兜底 → 事务回滚 → C 端同意必然 500; +-- * deny 路径:写失败被 catch 吞掉 → C 端越权尝试零审计(违反 DEC-006 全程留痕)。 +-- H2 测试库 audit_logs 无 FK → 集成测试假绿(测试库缺约束类假绿第二例)。 +-- +-- 方案:审计主体双维度——operator 主体照旧写 operator_id(FK operators); +-- user 主体写 subject_user_id(FK users)且 operator_id 置 null(本就可空)。 +-- DEC-006 第八节 actor 语义扩展:operator_id 与 subject_user_id 恰有一者非空。 + +ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS subject_user_id UUID REFERENCES users(id); + +CREATE INDEX IF NOT EXISTS idx_audit_logs_subject_user ON audit_logs (subject_user_id); diff --git a/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java b/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java index 53cfcd3..5c3ba34 100644 --- a/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java +++ b/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java @@ -38,7 +38,7 @@ class MigrationValidationTest { // actor_role_snapshot / request_trace_id 四列(全可空、存量不回填、幂等) // ZC-MM-003(B1 例外通道立项):V056 matchmaker_contact_authorizations 受控联系授权表 // (六态状态机 + active 部分唯一索引) - private static final int LATEST_MIGRATION = 56; + private static final int LATEST_MIGRATION = 57; private static final String MIGRATION_DIR = "backend/src/main/resources/db/migration/"; 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 2a71684..fe8396c 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 @@ -232,8 +232,6 @@ class MatchmakerContactAuthorizationTest { AuditLog audit = latestContactAudit(pair[0], auth.getId()); assertNotNull(audit, pair[0] + " 审计行必须存在"); assertEquals(pair[1], audit.getPurposeCode(), pair[0] + " purpose_code 必须落库"); - assertEquals(assignmentId, audit.getServiceAssignmentId(), - pair[0] + " 必须关联 service_assignment_id"); assertEquals("contacttest0001", audit.getRequestTraceId(), pair[0] + " 必须携带请求追踪 ID"); assertFalse(String.valueOf(audit.getAfterData()).contains(userPhone), @@ -245,6 +243,17 @@ class MatchmakerContactAuthorizationTest { .readTree(String.valueOf(audit.getAfterData())), pair[0] + " afterData 必须可 JSON 解析(PG jsonb 严格校验口径)"); assertTrue(afterJson.isObject(), pair[0] + " afterData 必须是 JSON 对象"); + // V057 主体维度分流:C 端用户动作(consent)写 subject_user_id 且 operator_id=null; + // operator 动作写 operator_id + service_assignment_id。混写必撞 audit_logs_operator_id_fkey。 + if (Constants.CONTACT_AUDIT_ACTION_CONSENT.equals(pair[0])) { + assertNull(audit.getOperatorId(), pair[0] + " 用户主体审计 operator_id 必须为 null"); + assertEquals(user, audit.getSubjectUserId(), pair[0] + " 必须写 subject_user_id 维度"); + assertNull(audit.getServiceAssignmentId(), pair[0] + " 用户动作无分配关联"); + } else { + assertEquals(assignmentId, audit.getServiceAssignmentId(), + pair[0] + " 必须关联 service_assignment_id"); + assertNull(audit.getSubjectUserId(), pair[0] + " operator 审计不得写 subject_user_id"); + } } } @@ -417,6 +426,16 @@ class MatchmakerContactAuthorizationTest { assertThrows(AccessDeniedException.class, () -> contactAuthorizationService.consent(auth.getId(), otherUser), "仅本人可同意授权"); + // V057:C 端越权尝试的 deny 审计必须落 subject_user_id 维度 + //(此前写 operator_id 维度撞 FK 被吞 → 生产零审计) + AuditLog denyAudit = auditLogMapper.selectList(new LambdaQueryWrapper() + .eq(AuditLog::getAction, "DATA_SCOPE_DENIED") + .eq(AuditLog::getSubjectUserId, otherUser) + .eq(AuditLog::getResourceId, auth.getId()) + .orderByDesc(AuditLog::getCreatedAt)) + .stream().findFirst().orElse(null); + assertNotNull(denyAudit, "非本人 consent 的越权尝试必须落 DATA_SCOPE_DENIED(subject_user_id 维度)"); + assertNull(denyAudit.getOperatorId(), "用户主体 deny 审计 operator_id 必须为 null"); } // ==================== 状态机非法转换 CONFLICT ==================== diff --git a/backend/src/test/resources/schema-h2.sql b/backend/src/test/resources/schema-h2.sql index 1602da1..d4c7d77 100644 --- a/backend/src/test/resources/schema-h2.sql +++ b/backend/src/test/resources/schema-h2.sql @@ -111,7 +111,9 @@ CREATE TABLE audit_logs ( 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) + actor_role_snapshot VARCHAR(128), request_trace_id VARCHAR(64), + -- V057(运行时实测修复):C 端用户主体维度(operator_id 与 subject_user_id 恰有一者非空) + subject_user_id VARCHAR(36) ); CREATE TABLE notifications ( id VARCHAR(36) PRIMARY KEY, user_id VARCHAR(36), type VARCHAR(32), title VARCHAR(200), content VARCHAR(2000),