优化
This commit is contained in:
+53
@@ -0,0 +1,53 @@
|
||||
package com.baiyue.lastleg.security.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
|
||||
import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.Acknowledgement;
|
||||
import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationRequest;
|
||||
import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationResponse;
|
||||
import com.baiyue.lastleg.security.service.LastlegSecurityProjectionService;
|
||||
import com.baiyue.lastleg.security.service.LastlegSecurityValidationService;
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Hidden
|
||||
@RestController
|
||||
@RequestMapping("/internal/lastleg/security/v1")
|
||||
public class LastlegSecurityController {
|
||||
private final InternalServiceTokenGuard tokenGuard;
|
||||
private final LastlegSecurityProjectionService projections;
|
||||
private final LastlegSecurityValidationService validations;
|
||||
|
||||
public LastlegSecurityController(InternalServiceTokenGuard tokenGuard,
|
||||
LastlegSecurityProjectionService projections,
|
||||
LastlegSecurityValidationService validations) {
|
||||
this.tokenGuard = tokenGuard;
|
||||
this.projections = projections;
|
||||
this.validations = validations;
|
||||
}
|
||||
|
||||
@PostMapping("/events")
|
||||
public ApiResponse<Acknowledgement> receive(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestBody EventEnvelope envelope) {
|
||||
tokenGuard.verify(token);
|
||||
var result = projections.consume(envelope);
|
||||
return ApiResponse.success(new Acknowledgement(envelope.eventId(), result.result(), Instant.now()));
|
||||
}
|
||||
|
||||
@PostMapping("/validate")
|
||||
public ApiResponse<ValidationResponse> validate(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@Valid @RequestBody ValidationRequest request) {
|
||||
tokenGuard.verify(token);
|
||||
return ApiResponse.success(validations.validate(request));
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.baiyue.lastleg.security.dto;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public final class LastlegSecurityDtos {
|
||||
private LastlegSecurityDtos() { }
|
||||
|
||||
public record Acknowledgement(String eventId, String result, Instant receivedAt) { }
|
||||
|
||||
public record ValidationRequest(@NotNull @Min(1) Long userId,
|
||||
@NotNull @Min(0) Long tenantId,
|
||||
@NotNull @Min(0) Long permissionVersion) { }
|
||||
|
||||
public record ValidationResponse(boolean valid, FailureReason failureReason) {
|
||||
public static ValidationResponse accepted() { return new ValidationResponse(true, null); }
|
||||
public static ValidationResponse rejected(FailureReason reason) {
|
||||
return new ValidationResponse(false, reason);
|
||||
}
|
||||
}
|
||||
|
||||
public enum FailureReason {
|
||||
SECURITY_PROJECTION_MISSING,
|
||||
SECURITY_PROJECTION_NOT_READY,
|
||||
USER_DISABLED,
|
||||
TENANT_DISABLED,
|
||||
PERMISSION_VERSION_STALE
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.baiyue.lastleg.security.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.event.TransactionPhase;
|
||||
import org.springframework.transaction.event.TransactionalEventListener;
|
||||
|
||||
/** 非授权真相的可重建 Redis 镜像;失败不影响已提交的 MySQL 投影。 */
|
||||
@Component
|
||||
@Slf4j
|
||||
public class LastlegSecurityProjectionRedisMirror {
|
||||
private final StringRedisTemplate redis;
|
||||
private final ObjectMapper objectMapper;
|
||||
@Value("${baiyue.security.permission-snapshot-key-prefix:baiyue:permission-snapshot}")
|
||||
private String keyPrefix;
|
||||
|
||||
public LastlegSecurityProjectionRedisMirror(StringRedisTemplate redis, ObjectMapper objectMapper) {
|
||||
this.redis = redis;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
|
||||
public void mirror(LastlegSecurityProjectionService.ProjectionChanged event) {
|
||||
var value = event.projection();
|
||||
try {
|
||||
redis.opsForValue().set(keyPrefix + ':' + value.aggregateType() + ':' + value.aggregateId(),
|
||||
objectMapper.writeValueAsString(value));
|
||||
} catch (Exception exception) {
|
||||
log.warn("海外安全投影 Redis 镜像写入失败 aggregateType={}, aggregateId={}",
|
||||
value.aggregateType(), value.aggregateId());
|
||||
}
|
||||
}
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
package com.baiyue.lastleg.security.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.EventPayloadCanonicalizer;
|
||||
import com.baiyue.common.event.EventPayloadHash;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.common.event.IamSecurityEventsV1;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
@Service
|
||||
public class LastlegSecurityProjectionService {
|
||||
private static final List<String> TYPES = List.of(EventTypes.IAM_USER_STATUS_CHANGED,
|
||||
EventTypes.IAM_TENANT_STATUS_CHANGED, EventTypes.IAM_PERMISSION_VERSION_CHANGED);
|
||||
private final JdbcTemplate jdbc;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ApplicationEventPublisher events;
|
||||
|
||||
public LastlegSecurityProjectionService(JdbcTemplate jdbc, ObjectMapper objectMapper,
|
||||
ApplicationEventPublisher events) {
|
||||
this.jdbc = jdbc;
|
||||
this.objectMapper = objectMapper;
|
||||
this.events = events;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ConsumeResult consume(EventEnvelope envelope) {
|
||||
envelope.requireValid();
|
||||
requireSecurityEnvelope(envelope);
|
||||
String payloadHash = validatedStorageHash(envelope);
|
||||
String payloadJson = envelope.payload().toString();
|
||||
try {
|
||||
jdbc.update("INSERT INTO ll_security_event_inbox(event_id,event_type,payload_hash,payload_json,status,received_at) "
|
||||
+ "VALUES (?,?,?,?, 'RECEIVED', NOW(3))", envelope.eventId(), envelope.eventType(),
|
||||
payloadHash, payloadJson);
|
||||
} catch (DuplicateKeyException duplicate) {
|
||||
InboxRow existing = jdbc.queryForObject(
|
||||
"SELECT event_type,payload_hash,status FROM ll_security_event_inbox "
|
||||
+ "WHERE event_id=? FOR UPDATE",
|
||||
(rs, row) -> new InboxRow(rs.getString(1), rs.getString(2), rs.getString(3)),
|
||||
envelope.eventId());
|
||||
if (existing == null || !Objects.equals(existing.eventType(), envelope.eventType())
|
||||
|| existing.payloadHash() == null
|
||||
|| !existing.payloadHash().equalsIgnoreCase(payloadHash)) {
|
||||
throw new BizException(ResultCode.CONFLICT, "相同 eventId 的 payloadHash 冲突");
|
||||
}
|
||||
if ("PROCESSED".equals(existing.status())) return new ConsumeResult("DUPLICATE", null);
|
||||
}
|
||||
|
||||
ProjectionInput input = input(envelope, payloadHash);
|
||||
ProjectionRow current = findForUpdate(input.aggregateType(), input.aggregateId());
|
||||
SecurityProjectionMonotonicity.Decision decision = SecurityProjectionMonotonicity.decide(
|
||||
current == null ? null : current.aggregateVersion(), current == null ? null : current.payloadHash(),
|
||||
input.aggregateVersion(), input.payloadHash());
|
||||
String result = decision.name();
|
||||
if (decision == SecurityProjectionMonotonicity.Decision.APPLY) {
|
||||
upsert(input);
|
||||
result = "APPLIED";
|
||||
events.publishEvent(new ProjectionChanged(input));
|
||||
}
|
||||
jdbc.update("UPDATE ll_security_event_inbox SET status='PROCESSED',result=?,processed_at=NOW(3) "
|
||||
+ "WHERE event_id=?", result, envelope.eventId());
|
||||
return new ConsumeResult(result, input);
|
||||
}
|
||||
|
||||
private void requireSecurityEnvelope(EventEnvelope envelope) {
|
||||
if (!TYPES.contains(envelope.eventType()) || envelope.eventVersion() != IamSecurityEventsV1.VERSION
|
||||
|| envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "仅接受 CN→OVERSEAS IAM V1 安全事件");
|
||||
}
|
||||
}
|
||||
|
||||
static String validatedStorageHash(EventEnvelope envelope) {
|
||||
if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()),
|
||||
envelope.payloadHash())) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "payloadHash 与 IAM 安全事件负载不一致");
|
||||
}
|
||||
// 表内只保存固定 64 位摘要;信封传输契约仍保留 sha256: 前缀。
|
||||
return envelope.payloadHash().substring("sha256:".length()).toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private ProjectionInput input(EventEnvelope envelope, String payloadHash) {
|
||||
try {
|
||||
return switch (envelope.eventType()) {
|
||||
case EventTypes.IAM_USER_STATUS_CHANGED -> {
|
||||
var value = objectMapper.treeToValue(envelope.payload(),
|
||||
IamSecurityEventsV1.UserStatusChanged.class);
|
||||
requireIdentity(envelope, "USER", value.authUserId(), value.tenantId());
|
||||
requirePayload(value.status(), value.changedAt(), value.permissionVersion(),
|
||||
envelope.aggregateVersion());
|
||||
requireStatus(value.status());
|
||||
yield new ProjectionInput("USER", String.valueOf(value.authUserId()), value.tenantId(),
|
||||
value.status(), value.permissionVersion(), envelope.aggregateVersion(),
|
||||
payloadHash, envelope.eventId(), value.changedAt());
|
||||
}
|
||||
case EventTypes.IAM_TENANT_STATUS_CHANGED -> {
|
||||
var value = objectMapper.treeToValue(envelope.payload(),
|
||||
IamSecurityEventsV1.TenantStatusChanged.class);
|
||||
requireIdentity(envelope, "TENANT", value.tenantId(), value.tenantId());
|
||||
requirePayload(value.status(), value.changedAt(), envelope.aggregateVersion(),
|
||||
envelope.aggregateVersion());
|
||||
requireStatus(value.status());
|
||||
yield new ProjectionInput("TENANT", String.valueOf(value.tenantId()), value.tenantId(),
|
||||
value.status(), null, envelope.aggregateVersion(), payloadHash,
|
||||
envelope.eventId(), value.changedAt());
|
||||
}
|
||||
case EventTypes.IAM_PERMISSION_VERSION_CHANGED -> {
|
||||
var value = objectMapper.treeToValue(envelope.payload(),
|
||||
IamSecurityEventsV1.PermissionVersionChanged.class);
|
||||
requireIdentity(envelope, "AUTHORIZATION", value.authUserId(), value.tenantId());
|
||||
requirePayload(value.appCode(), value.changedAt(), value.permissionVersion(),
|
||||
envelope.aggregateVersion());
|
||||
yield new ProjectionInput("AUTHORIZATION", String.valueOf(value.authUserId()), value.tenantId(),
|
||||
null, value.permissionVersion(), envelope.aggregateVersion(), payloadHash,
|
||||
envelope.eventId(), value.changedAt());
|
||||
}
|
||||
default -> throw new IllegalArgumentException("unsupported type");
|
||||
};
|
||||
} catch (BizException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "IAM 安全事件 payload 无效");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireIdentity(EventEnvelope envelope, String aggregateType,
|
||||
long aggregateId, long tenantId) {
|
||||
if (!aggregateType.equals(envelope.aggregateType())
|
||||
|| !String.valueOf(aggregateId).equals(envelope.aggregateId())
|
||||
|| !String.valueOf(tenantId).equals(envelope.tenantId())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "IAM 安全事件信封与 payload 身份不一致");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requirePayload(String requiredText, Instant changedAt,
|
||||
long payloadVersion, long aggregateVersion) {
|
||||
if (requiredText == null || requiredText.isBlank() || changedAt == null
|
||||
|| payloadVersion != aggregateVersion) {
|
||||
throw new BizException(ResultCode.CONFLICT, "IAM 安全事件 payload 版本或必填字段无效");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireStatus(String status) {
|
||||
if (!"ACTIVE".equals(status) && !"DISABLED".equals(status)) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "IAM 安全状态仅允许 ACTIVE 或 DISABLED");
|
||||
}
|
||||
}
|
||||
|
||||
private ProjectionRow findForUpdate(String type, String id) {
|
||||
List<ProjectionRow> rows = jdbc.query(
|
||||
"SELECT aggregate_version,payload_hash FROM ll_security_projection "
|
||||
+ "WHERE aggregate_type=? AND aggregate_id=? FOR UPDATE",
|
||||
(rs, row) -> new ProjectionRow(rs.getLong(1), rs.getString(2)), type, id);
|
||||
return rows.isEmpty() ? null : rows.get(0);
|
||||
}
|
||||
|
||||
private void upsert(ProjectionInput value) {
|
||||
jdbc.update("INSERT INTO ll_security_projection(aggregate_type,aggregate_id,tenant_id,security_status,"
|
||||
+ "permission_version,aggregate_version,payload_hash,last_event_id,changed_at,updated_at) "
|
||||
+ "VALUES (?,?,?,?,?,?,?,?,?,NOW(3)) ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id),"
|
||||
+ "security_status=VALUES(security_status),permission_version=VALUES(permission_version),"
|
||||
+ "aggregate_version=VALUES(aggregate_version),payload_hash=VALUES(payload_hash),"
|
||||
+ "last_event_id=VALUES(last_event_id),changed_at=VALUES(changed_at),updated_at=NOW(3)",
|
||||
value.aggregateType(), value.aggregateId(), value.tenantId(), value.status(),
|
||||
value.permissionVersion(), value.aggregateVersion(), value.payloadHash(), value.eventId(),
|
||||
value.changedAt());
|
||||
}
|
||||
|
||||
private record InboxRow(String eventType, String payloadHash, String status) { }
|
||||
private record ProjectionRow(long aggregateVersion, String payloadHash) { }
|
||||
public record ConsumeResult(String result, ProjectionInput projection) { }
|
||||
public record ProjectionChanged(ProjectionInput projection) { }
|
||||
public record ProjectionInput(String aggregateType, String aggregateId, long tenantId, String status,
|
||||
Long permissionVersion, long aggregateVersion, String payloadHash,
|
||||
String eventId, Instant changedAt) { }
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package com.baiyue.lastleg.security.service;
|
||||
|
||||
import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.FailureReason;
|
||||
import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationRequest;
|
||||
import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationResponse;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class LastlegSecurityValidationService {
|
||||
private final JdbcTemplate jdbc;
|
||||
|
||||
public LastlegSecurityValidationService(JdbcTemplate jdbc) {
|
||||
this.jdbc = jdbc;
|
||||
}
|
||||
|
||||
public ValidationResponse validate(ValidationRequest request) {
|
||||
String userStatus = status("USER", String.valueOf(request.userId()), request.tenantId());
|
||||
if (userStatus == null) {
|
||||
return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING);
|
||||
}
|
||||
if (!"ACTIVE".equals(userStatus)) {
|
||||
return ValidationResponse.rejected(FailureReason.USER_DISABLED);
|
||||
}
|
||||
if (request.tenantId() > 0) {
|
||||
String tenantStatus = status("TENANT", String.valueOf(request.tenantId()), request.tenantId());
|
||||
if (tenantStatus == null) {
|
||||
return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING);
|
||||
}
|
||||
if (!"ACTIVE".equals(tenantStatus)) {
|
||||
return ValidationResponse.rejected(FailureReason.TENANT_DISABLED);
|
||||
}
|
||||
}
|
||||
List<Long> projectedVersions = jdbc.query(
|
||||
"SELECT MAX(permission_version) FROM ll_security_projection "
|
||||
+ "WHERE aggregate_id=? AND tenant_id=? "
|
||||
+ "AND aggregate_type IN ('USER','AUTHORIZATION') HAVING COUNT(permission_version)>0",
|
||||
(rs, row) -> rs.getLong(1), String.valueOf(request.userId()), request.tenantId());
|
||||
if (projectedVersions.isEmpty()) {
|
||||
return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING);
|
||||
}
|
||||
long projectedVersion = projectedVersions.get(0);
|
||||
if (request.permissionVersion() < projectedVersion) {
|
||||
return ValidationResponse.rejected(FailureReason.PERMISSION_VERSION_STALE);
|
||||
}
|
||||
if (request.permissionVersion() > projectedVersion) {
|
||||
return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_NOT_READY);
|
||||
}
|
||||
return ValidationResponse.accepted();
|
||||
}
|
||||
|
||||
private String status(String type, String id, long tenantId) {
|
||||
List<String> values = jdbc.query("SELECT security_status FROM ll_security_projection "
|
||||
+ "WHERE aggregate_type=? AND aggregate_id=? AND tenant_id=?",
|
||||
(rs, row) -> rs.getString(1), type, id, tenantId);
|
||||
return values.isEmpty() ? null : values.get(0);
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.lastleg.security.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
final class SecurityProjectionMonotonicity {
|
||||
private SecurityProjectionMonotonicity() { }
|
||||
|
||||
static Decision decide(Long currentVersion, String currentHash, long incomingVersion, String incomingHash) {
|
||||
if (currentVersion == null || incomingVersion > currentVersion) return Decision.APPLY;
|
||||
if (incomingVersion < currentVersion) return Decision.STALE;
|
||||
if (Objects.equals(currentHash, incomingHash)) return Decision.DUPLICATE_VERSION;
|
||||
throw new BizException(ResultCode.CONFLICT, "相同聚合版本的安全事件 payloadHash 冲突");
|
||||
}
|
||||
|
||||
enum Decision { APPLY, STALE, DUPLICATE_VERSION }
|
||||
}
|
||||
Reference in New Issue
Block a user