fix(audit,matchmaker): V057 audit_logs 增加 subject_user_id 主体维度(运行时实测抓到 C 端审计 FK 硬缺陷)

- 根因: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 失败日志溯源)
This commit is contained in:
WorkBuddy
2026-10-12 10:15:46 +08:00
parent 02bc01f9c9
commit ef61d51f61
8 changed files with 122 additions and 11 deletions
@@ -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;
}
@@ -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);
}
}
@@ -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)。
@@ -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。
@@ -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);
@@ -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/";
@@ -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<AuditLog>()
.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 ====================
+3 -1
View File
@@ -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),