优化
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
# Overseas Security Service 模块指引
|
||||
|
||||
## 职责边界
|
||||
|
||||
- 消费 IAM 三类跨区安全事件,维护海外可靠 Inbox 与单调安全投影,并为海外 Gateway 提供内部实时校验。
|
||||
- MySQL 是恢复源;Redis 仅为可重建镜像,不能成为授权真相或在失败时默认放行。
|
||||
- 不签发 Token、不保存 raw Token/凭据、不承载 Lastleg 业务,也不解释 Event Bridge 的重试状态。
|
||||
|
||||
## 入口
|
||||
|
||||
- 内部事件与校验:`controller/OverseasSecurityController.java`
|
||||
- 幂等、单调投影:`service/SecurityProjectionService.java`
|
||||
- Token 校验:`service/OverseasSecurityValidationService.java`
|
||||
- SQL:`src/main/resources/db/overseas-security.sql`
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent><groupId>com.baiyue.firstleg</groupId><artifactId>baiyue-firstleg-platform</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version><relativePath>../pom.xml</relativePath></parent>
|
||||
<artifactId>overseas-security-service</artifactId>
|
||||
<dependencies>
|
||||
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-core</artifactId><version>${project.version}</version></dependency>
|
||||
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-web</artifactId><version>${project.version}</version></dependency>
|
||||
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-openapi</artifactId><version>${project.version}</version></dependency>
|
||||
<dependency><groupId>com.baiyue.shared</groupId><artifactId>common-event-contract</artifactId><version>${project.version}</version></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency>
|
||||
<dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency>
|
||||
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId></dependency>
|
||||
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
|
||||
</dependencies>
|
||||
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
|
||||
</project>
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.baiyue.overseassecurity;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class OverseasSecurityApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(OverseasSecurityApplication.class, args);
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package com.baiyue.overseassecurity.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.Acknowledgement;
|
||||
import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.ValidationRequest;
|
||||
import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.ValidationResponse;
|
||||
import com.baiyue.overseassecurity.security.InternalServiceTokenGuard;
|
||||
import com.baiyue.overseassecurity.service.OverseasSecurityValidationService;
|
||||
import com.baiyue.overseassecurity.service.SecurityProjectionService;
|
||||
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/overseas-security/v1")
|
||||
public class OverseasSecurityController {
|
||||
private final InternalServiceTokenGuard tokenGuard;
|
||||
private final SecurityProjectionService projections;
|
||||
private final OverseasSecurityValidationService validations;
|
||||
|
||||
public OverseasSecurityController(InternalServiceTokenGuard tokenGuard, SecurityProjectionService projections,
|
||||
OverseasSecurityValidationService 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));
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.baiyue.overseassecurity.dto;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public final class OverseasSecurityDtos {
|
||||
private OverseasSecurityDtos() { }
|
||||
|
||||
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 tokenVersion,
|
||||
@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 {
|
||||
USER_DISABLED,
|
||||
TENANT_DISABLED,
|
||||
PERMISSION_VERSION_STALE
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.baiyue.overseassecurity.security;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
@Component
|
||||
public class InternalServiceTokenGuard {
|
||||
@Value("${baiyue.security.internal-service-token:}") private String configuredToken;
|
||||
private byte[] expected;
|
||||
|
||||
@PostConstruct void initialize() {
|
||||
expected = configuredToken == null ? new byte[0] : configuredToken.getBytes(StandardCharsets.UTF_8);
|
||||
if (expected.length < 32) throw new IllegalStateException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节");
|
||||
}
|
||||
|
||||
public void verify(String supplied) {
|
||||
byte[] actual = supplied == null ? new byte[0] : supplied.getBytes(StandardCharsets.UTF_8);
|
||||
if (!MessageDigest.isEqual(expected, actual)) throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效");
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package com.baiyue.overseassecurity.service;
|
||||
|
||||
import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.FailureReason;
|
||||
import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.ValidationRequest;
|
||||
import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.ValidationResponse;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class OverseasSecurityValidationService {
|
||||
private final JdbcTemplate jdbc;
|
||||
|
||||
public OverseasSecurityValidationService(JdbcTemplate jdbc) {
|
||||
this.jdbc = jdbc;
|
||||
}
|
||||
|
||||
public ValidationResponse validate(ValidationRequest request) {
|
||||
String userStatus = status("USER", String.valueOf(request.userId()));
|
||||
if (userStatus != null && !"ACTIVE".equals(userStatus)) {
|
||||
return ValidationResponse.rejected(FailureReason.USER_DISABLED);
|
||||
}
|
||||
String tenantStatus = status("TENANT", String.valueOf(request.tenantId()));
|
||||
if (tenantStatus != null && !"ACTIVE".equals(tenantStatus)) {
|
||||
return ValidationResponse.rejected(FailureReason.TENANT_DISABLED);
|
||||
}
|
||||
Long projectedVersion = jdbc.queryForObject(
|
||||
"SELECT MAX(permission_version) FROM overseas_security_projection "
|
||||
+ "WHERE aggregate_id=? AND aggregate_type IN ('USER','AUTHORIZATION')",
|
||||
Long.class, String.valueOf(request.userId()));
|
||||
if (projectedVersion != null && request.permissionVersion() < projectedVersion) {
|
||||
return ValidationResponse.rejected(FailureReason.PERMISSION_VERSION_STALE);
|
||||
}
|
||||
return ValidationResponse.accepted();
|
||||
}
|
||||
|
||||
private String status(String type, String id) {
|
||||
List<String> values = jdbc.query("SELECT security_status FROM overseas_security_projection "
|
||||
+ "WHERE aggregate_type=? AND aggregate_id=?",
|
||||
(rs, row) -> rs.getString(1), type, id);
|
||||
return values.isEmpty() ? null : values.get(0);
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.overseassecurity.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
final class ProjectionMonotonicity {
|
||||
private ProjectionMonotonicity() { }
|
||||
|
||||
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 }
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.baiyue.overseassecurity.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 SecurityProjectionRedisMirror {
|
||||
private final StringRedisTemplate redis;
|
||||
private final ObjectMapper objectMapper;
|
||||
@Value("${baiyue.security.permission-snapshot-key-prefix:baiyue:permission-snapshot}")
|
||||
private String keyPrefix;
|
||||
|
||||
public SecurityProjectionRedisMirror(StringRedisTemplate redis, ObjectMapper objectMapper) {
|
||||
this.redis = redis;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
|
||||
public void mirror(SecurityProjectionService.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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
package com.baiyue.overseassecurity.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.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.Objects;
|
||||
|
||||
@Service
|
||||
public class SecurityProjectionService {
|
||||
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 SecurityProjectionService(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 payloadJson = envelope.payload().toString();
|
||||
try {
|
||||
jdbc.update("INSERT INTO overseas_security_event_inbox(event_id,event_type,payload_hash,payload_json,status,received_at) "
|
||||
+ "VALUES (?,?,?,?, 'RECEIVED', NOW(3))", envelope.eventId(), envelope.eventType(),
|
||||
envelope.payloadHash(), payloadJson);
|
||||
} catch (DuplicateKeyException duplicate) {
|
||||
InboxRow existing = jdbc.queryForObject("SELECT event_type,payload_hash,status FROM overseas_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())
|
||||
|| !Objects.equals(existing.payloadHash(), envelope.payloadHash())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "相同 eventId 的 payloadHash 冲突");
|
||||
}
|
||||
if ("PROCESSED".equals(existing.status())) return new ConsumeResult("DUPLICATE", null);
|
||||
}
|
||||
|
||||
ProjectionInput input = input(envelope);
|
||||
ProjectionRow current = findForUpdate(input.aggregateType(), input.aggregateId());
|
||||
ProjectionMonotonicity.Decision decision = ProjectionMonotonicity.decide(
|
||||
current == null ? null : current.aggregateVersion(), current == null ? null : current.payloadHash(),
|
||||
input.aggregateVersion(), input.payloadHash());
|
||||
String result = decision.name();
|
||||
if (decision == ProjectionMonotonicity.Decision.APPLY) {
|
||||
upsert(input);
|
||||
result = "APPLIED";
|
||||
events.publishEvent(new ProjectionChanged(input));
|
||||
}
|
||||
jdbc.update("UPDATE overseas_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 安全事件");
|
||||
}
|
||||
}
|
||||
|
||||
private ProjectionInput input(EventEnvelope envelope) {
|
||||
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());
|
||||
yield new ProjectionInput("USER", String.valueOf(value.authUserId()), value.tenantId(),
|
||||
value.status(), value.permissionVersion(), envelope.aggregateVersion(), envelope.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());
|
||||
yield new ProjectionInput("TENANT", String.valueOf(value.tenantId()), value.tenantId(),
|
||||
value.status(), null, envelope.aggregateVersion(), envelope.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(), envelope.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 ProjectionRow findForUpdate(String type, String id) {
|
||||
List<ProjectionRow> rows = jdbc.query("SELECT aggregate_version,payload_hash FROM overseas_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 overseas_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) { }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
server:
|
||||
port: ${OVERSEAS_SECURITY_PORT:9307}
|
||||
spring:
|
||||
application.name: overseas-security-service
|
||||
config.import:
|
||||
- optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true
|
||||
- optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true
|
||||
datasource:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: ${OVERSEAS_SECURITY_DB_URL}
|
||||
username: ${OVERSEAS_SECURITY_DB_USERNAME}
|
||||
password: ${OVERSEAS_SECURITY_DB_PASSWORD}
|
||||
data.redis:
|
||||
host: ${REDIS_HOST:127.0.0.1}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:}
|
||||
database: ${REDIS_DATABASE:0}
|
||||
cloud.nacos:
|
||||
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
|
||||
username: ${NACOS_USERNAME:nacos}
|
||||
password: ${NACOS_PASSWORD:nacos}
|
||||
discovery:
|
||||
group: ${NACOS_GROUP:BAIYUE}
|
||||
namespace: ${NACOS_NAMESPACE:prod-overseas}
|
||||
baiyue.security:
|
||||
internal-service-token: ${INTERNAL_SERVICE_TOKEN}
|
||||
permission-snapshot-key-prefix: ${PERMISSION_SNAPSHOT_KEY_PREFIX:baiyue:permission-snapshot}
|
||||
management.endpoints.web.exposure.include: health,info,prometheus
|
||||
@@ -0,0 +1,31 @@
|
||||
CREATE DATABASE IF NOT EXISTS baiyue_overseas_security DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||
USE baiyue_overseas_security;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS overseas_security_event_inbox (
|
||||
event_id VARCHAR(64) NOT NULL COMMENT '跨区事件ID',
|
||||
event_type VARCHAR(96) NOT NULL COMMENT 'IAM安全事件类型',
|
||||
payload_hash CHAR(64) NOT NULL COMMENT '规范化Payload SHA-256',
|
||||
payload_json JSON NOT NULL COMMENT '冻结事件Payload',
|
||||
status VARCHAR(16) NOT NULL COMMENT 'RECEIVED/PROCESSED',
|
||||
result VARCHAR(32) NULL COMMENT 'APPLIED/STALE/DUPLICATE_VERSION',
|
||||
received_at DATETIME(3) NOT NULL,
|
||||
processed_at DATETIME(3) NULL,
|
||||
PRIMARY KEY (event_id),
|
||||
KEY idx_security_inbox_type_time (event_type, received_at)
|
||||
) ENGINE=InnoDB COMMENT='海外IAM安全事件消费者Inbox';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS overseas_security_projection (
|
||||
aggregate_type VARCHAR(32) NOT NULL COMMENT 'USER/TENANT/AUTHORIZATION',
|
||||
aggregate_id VARCHAR(64) NOT NULL COMMENT '用户或客户聚合ID',
|
||||
tenant_id BIGINT NOT NULL COMMENT '平台0或客户ID',
|
||||
security_status VARCHAR(24) NULL COMMENT 'ACTIVE/DISABLED等当前状态',
|
||||
permission_version BIGINT NULL COMMENT '当前单调权限版本',
|
||||
aggregate_version BIGINT NOT NULL COMMENT '事件聚合单调版本',
|
||||
payload_hash CHAR(64) NOT NULL COMMENT '当前版本Payload哈希',
|
||||
last_event_id VARCHAR(64) NOT NULL COMMENT '最后应用事件ID',
|
||||
changed_at DATETIME(3) NOT NULL COMMENT '国内IAM事实发生时间',
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
PRIMARY KEY (aggregate_type, aggregate_id),
|
||||
KEY idx_security_projection_tenant (tenant_id),
|
||||
KEY idx_security_projection_permission (aggregate_id, permission_version)
|
||||
) ENGINE=InnoDB COMMENT='海外IAM安全状态与权限版本投影';
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Overseas Security Projection Internal API
|
||||
version: 1.0.0
|
||||
paths:
|
||||
/internal/overseas-security/v1/events:
|
||||
post:
|
||||
summary: Consume one IAM security EventEnvelope
|
||||
security: [{ internalServiceToken: [] }]
|
||||
responses:
|
||||
'200':
|
||||
description: "Acknowledgement contains eventId, result and receivedAt"
|
||||
/internal/overseas-security/v1/validate:
|
||||
post:
|
||||
summary: Validate locally verified overseas JWT claim versions
|
||||
description: Accepts userId, tenantId, tokenVersion and permissionVersion; raw tokens and credentials are forbidden.
|
||||
security: [{ internalServiceToken: [] }]
|
||||
responses:
|
||||
'200':
|
||||
description: "valid plus optional USER_DISABLED, TENANT_DISABLED or PERMISSION_VERSION_STALE reason"
|
||||
components:
|
||||
securitySchemes:
|
||||
internalServiceToken:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: X-Internal-Service-Token
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package com.baiyue.overseassecurity;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class OverseasSecurityContractTest {
|
||||
@Test void sqlFreezesReliableInboxProjectionAndRedisRecoveryBoundary() throws Exception {
|
||||
String sql = read("overseas-security-service/src/main/resources/db/overseas-security.sql");
|
||||
String service = read("overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionService.java");
|
||||
String validation = read("overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationService.java");
|
||||
String mirror = read("overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionRedisMirror.java");
|
||||
assertThat(sql).contains("PRIMARY KEY (event_id)", "payload_hash CHAR(64)",
|
||||
"PRIMARY KEY (aggregate_type, aggregate_id)", "aggregate_version BIGINT", "permission_version BIGINT");
|
||||
assertThat(service).contains("ProjectionMonotonicity.decide", "Decision.APPLY");
|
||||
assertThat(mirror).contains("TransactionPhase.AFTER_COMMIT", "Redis 镜像写入失败");
|
||||
assertThat(validation).contains("SELECT MAX(permission_version)", "USER_DISABLED",
|
||||
"TENANT_DISABLED", "PERMISSION_VERSION_STALE");
|
||||
}
|
||||
|
||||
@Test void eventBridgeRoutesIamTypesWithoutInspectingTheirPayload() throws Exception {
|
||||
String dispatcher = read("event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java");
|
||||
String client = read("event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java");
|
||||
String lastleg = read("lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java");
|
||||
assertThat(dispatcher).contains("IAM_USER_STATUS_CHANGED", "IAM_TENANT_STATUS_CHANGED",
|
||||
"IAM_PERMISSION_VERSION_CHANGED", "securityProjectionEventClient.deliver(event)");
|
||||
assertThat(dispatcher).doesNotContain("IamSecurityEventsV1", "permissionVersion()", "status()");
|
||||
assertThat(client).contains("path(\"data\").path(\"eventId\")", "row.getEventId().equals")
|
||||
.doesNotContain("IamSecurityEventsV1");
|
||||
assertThat(lastleg).contains("Lastleg 不消费该事件类型")
|
||||
.doesNotContain("? shippingNodeConsumer.consume(event) : consumer.consume(event)");
|
||||
}
|
||||
|
||||
@Test void internalOpenApiNeverAcceptsRawTokensOrCredentials() throws Exception {
|
||||
String openapi = read("overseas-security-service/src/main/resources/openapi/internal-overseas-security-v1.yaml");
|
||||
assertThat(openapi).contains("/internal/overseas-security/v1/events:",
|
||||
"/internal/overseas-security/v1/validate:", "X-Internal-Service-Token",
|
||||
"userId, tenantId, tokenVersion and permissionVersion");
|
||||
assertThat(openapi).doesNotContain("accessToken", "refreshToken", "password:", "Authorization");
|
||||
}
|
||||
|
||||
private static String read(String relative) throws Exception {
|
||||
Path path = Path.of(relative);
|
||||
if (!Files.exists(path)) path = Path.of("..").resolve(relative);
|
||||
return Files.readString(path, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.baiyue.overseassecurity.service;
|
||||
|
||||
import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.FailureReason;
|
||||
import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.ValidationRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class OverseasSecurityValidationServiceTest {
|
||||
private final JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
private final OverseasSecurityValidationService service = new OverseasSecurityValidationService(jdbc);
|
||||
|
||||
@Test void disabledUserAndTenantFailClosed() {
|
||||
when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class)))
|
||||
.thenReturn(List.of("DISABLED"));
|
||||
assertThat(service.validate(new ValidationRequest(42L, 0L, 3L, 8L)).failureReason())
|
||||
.isEqualTo(FailureReason.USER_DISABLED);
|
||||
}
|
||||
|
||||
@Test void stalePermissionVersionFailsAndCurrentVersionPasses() {
|
||||
when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class)))
|
||||
.thenReturn(List.of(), List.of());
|
||||
when(jdbc.queryForObject(anyString(), any(Class.class), any(Object[].class))).thenReturn(9L, 9L);
|
||||
assertThat(service.validate(new ValidationRequest(42L, 0L, 3L, 8L)).failureReason())
|
||||
.isEqualTo(FailureReason.PERMISSION_VERSION_STALE);
|
||||
assertThat(service.validate(new ValidationRequest(42L, 0L, 3L, 9L)).valid()).isTrue();
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.baiyue.overseassecurity.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class ProjectionMonotonicityTest {
|
||||
@Test void appliesFirstAndNewerVersions() {
|
||||
assertThat(ProjectionMonotonicity.decide(null, null, 1, "hash-1"))
|
||||
.isEqualTo(ProjectionMonotonicity.Decision.APPLY);
|
||||
assertThat(ProjectionMonotonicity.decide(1L, "hash-1", 2, "hash-2"))
|
||||
.isEqualTo(ProjectionMonotonicity.Decision.APPLY);
|
||||
}
|
||||
|
||||
@Test void ignoresOlderAndSameHashVersions() {
|
||||
assertThat(ProjectionMonotonicity.decide(3L, "hash-3", 2, "hash-2"))
|
||||
.isEqualTo(ProjectionMonotonicity.Decision.STALE);
|
||||
assertThat(ProjectionMonotonicity.decide(3L, "hash-3", 3, "hash-3"))
|
||||
.isEqualTo(ProjectionMonotonicity.Decision.DUPLICATE_VERSION);
|
||||
}
|
||||
|
||||
@Test void rejectsSameVersionWithDifferentHash() {
|
||||
assertThatThrownBy(() -> ProjectionMonotonicity.decide(3L, "hash-a", 3, "hash-b"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("payloadHash 冲突");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user