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:
@@ -30,4 +30,9 @@ public class AuditLog extends BaseEntity {
|
|||||||
private String actorRoleSnapshot;
|
private String actorRoleSnapshot;
|
||||||
/** 请求追踪 ID(RequestIdFilter 写入 MDC 的 requestId)。 */
|
/** 请求追踪 ID(RequestIdFilter 写入 MDC 的 requestId)。 */
|
||||||
private String requestTraceId;
|
private String requestTraceId;
|
||||||
|
|
||||||
|
// ==================== V057 / 运行时实测修复:C 端用户主体维度 ====================
|
||||||
|
|
||||||
|
/** 用户主体 ID(C 端触发的审计写此列且 operator_id=null;两者恰有一者非空)。 */
|
||||||
|
private String subjectUserId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,4 +103,29 @@ public class AuditLogService {
|
|||||||
}
|
}
|
||||||
auditLogMapper.insert(auditLog);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-7
@@ -255,9 +255,8 @@ public class ContactAuthorizationService {
|
|||||||
authorizationMapper.updateById(auth);
|
authorizationMapper.updateById(auth);
|
||||||
|
|
||||||
log.info("受控联系授权用户同意: authId={}, userId={}", authId, userId);
|
log.info("受控联系授权用户同意: authId={}, userId={}", authId, userId);
|
||||||
saveAudit(userId, Constants.CONTACT_AUDIT_ACTION_CONSENT, authId,
|
saveUserAudit(userId, Constants.CONTACT_AUDIT_ACTION_CONSENT, authId,
|
||||||
activeAssignmentIdOf(auth), "matchmaker.contact.consent",
|
"matchmaker.contact.consent", jsonOf("channel", auth.getChannel()));
|
||||||
jsonOf("channel", auth.getChannel()));
|
|
||||||
return auth;
|
return auth;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,9 +271,8 @@ public class ContactAuthorizationService {
|
|||||||
authorizationMapper.updateById(auth);
|
authorizationMapper.updateById(auth);
|
||||||
|
|
||||||
log.info("受控联系授权用户拒绝: authId={}, userId={}", authId, userId);
|
log.info("受控联系授权用户拒绝: authId={}, userId={}", authId, userId);
|
||||||
saveAudit(userId, Constants.CONTACT_AUDIT_ACTION_DECLINE, authId,
|
saveUserAudit(userId, Constants.CONTACT_AUDIT_ACTION_DECLINE, authId,
|
||||||
activeAssignmentIdOf(auth), "matchmaker.contact.decline",
|
"matchmaker.contact.decline", jsonOf("channel", auth.getChannel()));
|
||||||
jsonOf("channel", auth.getChannel()));
|
|
||||||
return auth;
|
return auth;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,7 +422,9 @@ public class ContactAuthorizationService {
|
|||||||
private MatchmakerContactAuthorization requireOwnedAuthorization(String authId, String userId) {
|
private MatchmakerContactAuthorization requireOwnedAuthorization(String authId, String userId) {
|
||||||
MatchmakerContactAuthorization auth = requireAuthorization(authId);
|
MatchmakerContactAuthorization auth = requireAuthorization(authId);
|
||||||
if (!auth.getUserId().equals(userId)) {
|
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;
|
return auth;
|
||||||
}
|
}
|
||||||
@@ -454,6 +454,25 @@ public class ContactAuthorizationService {
|
|||||||
auditLogMapper.insert(auditLog);
|
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 严格解析;
|
* afterData 必须是合法 JSON(PG audit_logs.after_data 为 jsonb 严格解析;
|
||||||
* H2 测试库 CLOB 不校验——裸 key=value 拼接在测试侧假绿、生产侧 500)。
|
* H2 测试库 CLOB 不校验——裸 key=value 拼接在测试侧假绿、生产侧 500)。
|
||||||
|
|||||||
@@ -89,6 +89,30 @@ public class DataScopeGuard {
|
|||||||
throw new BusinessException(ApiResponseCode.NOT_FOUND, message);
|
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} 同口径):
|
* 当前操作者权限快照(与 {@link AuthorizationAuditListener} 同口径):
|
||||||
* OperatorPrincipal.permissions() 逗号拼接,截断 128。
|
* 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);
|
||||||
+1
-1
@@ -38,7 +38,7 @@ class MigrationValidationTest {
|
|||||||
// actor_role_snapshot / request_trace_id 四列(全可空、存量不回填、幂等)
|
// actor_role_snapshot / request_trace_id 四列(全可空、存量不回填、幂等)
|
||||||
// ZC-MM-003(B1 例外通道立项):V056 matchmaker_contact_authorizations 受控联系授权表
|
// ZC-MM-003(B1 例外通道立项):V056 matchmaker_contact_authorizations 受控联系授权表
|
||||||
// (六态状态机 + active 部分唯一索引)
|
// (六态状态机 + active 部分唯一索引)
|
||||||
private static final int LATEST_MIGRATION = 56;
|
private static final int LATEST_MIGRATION = 57;
|
||||||
|
|
||||||
private static final String MIGRATION_DIR =
|
private static final String MIGRATION_DIR =
|
||||||
"backend/src/main/resources/db/migration/";
|
"backend/src/main/resources/db/migration/";
|
||||||
|
|||||||
+21
-2
@@ -232,8 +232,6 @@ class MatchmakerContactAuthorizationTest {
|
|||||||
AuditLog audit = latestContactAudit(pair[0], auth.getId());
|
AuditLog audit = latestContactAudit(pair[0], auth.getId());
|
||||||
assertNotNull(audit, pair[0] + " 审计行必须存在");
|
assertNotNull(audit, pair[0] + " 审计行必须存在");
|
||||||
assertEquals(pair[1], audit.getPurposeCode(), pair[0] + " purpose_code 必须落库");
|
assertEquals(pair[1], audit.getPurposeCode(), pair[0] + " purpose_code 必须落库");
|
||||||
assertEquals(assignmentId, audit.getServiceAssignmentId(),
|
|
||||||
pair[0] + " 必须关联 service_assignment_id");
|
|
||||||
assertEquals("contacttest0001", audit.getRequestTraceId(),
|
assertEquals("contacttest0001", audit.getRequestTraceId(),
|
||||||
pair[0] + " 必须携带请求追踪 ID");
|
pair[0] + " 必须携带请求追踪 ID");
|
||||||
assertFalse(String.valueOf(audit.getAfterData()).contains(userPhone),
|
assertFalse(String.valueOf(audit.getAfterData()).contains(userPhone),
|
||||||
@@ -245,6 +243,17 @@ class MatchmakerContactAuthorizationTest {
|
|||||||
.readTree(String.valueOf(audit.getAfterData())),
|
.readTree(String.valueOf(audit.getAfterData())),
|
||||||
pair[0] + " afterData 必须可 JSON 解析(PG jsonb 严格校验口径)");
|
pair[0] + " afterData 必须可 JSON 解析(PG jsonb 严格校验口径)");
|
||||||
assertTrue(afterJson.isObject(), pair[0] + " afterData 必须是 JSON 对象");
|
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,
|
assertThrows(AccessDeniedException.class,
|
||||||
() -> contactAuthorizationService.consent(auth.getId(), otherUser),
|
() -> 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 ====================
|
// ==================== 状态机非法转换 CONFLICT ====================
|
||||||
|
|||||||
@@ -111,7 +111,9 @@ CREATE TABLE audit_logs (
|
|||||||
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):扩展审计字段,全可空,与生产对齐
|
-- V055(ZC-MM-006 / T01):扩展审计字段,全可空,与生产对齐
|
||||||
purpose_code VARCHAR(64), service_assignment_id VARCHAR(36),
|
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 (
|
CREATE TABLE notifications (
|
||||||
id VARCHAR(36) PRIMARY KEY, user_id VARCHAR(36), type VARCHAR(32), title VARCHAR(200), content VARCHAR(2000),
|
id VARCHAR(36) PRIMARY KEY, user_id VARCHAR(36), type VARCHAR(32), title VARCHAR(200), content VARCHAR(2000),
|
||||||
|
|||||||
Reference in New Issue
Block a user