This commit is contained in:
2026-08-25 14:46:06 +08:00
parent 5abc7543ae
commit f325d57fc1
106 changed files with 3682 additions and 450 deletions
@@ -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);
}
}
@@ -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));
}
}
@@ -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
}
}
@@ -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, "内部服务令牌无效");
}
}
@@ -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);
}
}
@@ -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 }
}
@@ -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());
}
}
}
@@ -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安全状态与权限版本投影';
@@ -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
@@ -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);
}
}
@@ -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();
}
}
@@ -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 冲突");
}
}