fix: project overseas authorization outside jwt
This commit is contained in:
+11
-3
@@ -4,6 +4,7 @@ import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Set;
|
||||
|
||||
public final class LastlegSecurityDtos {
|
||||
private LastlegSecurityDtos() { }
|
||||
@@ -14,10 +15,17 @@ public final class LastlegSecurityDtos {
|
||||
@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 record ValidationResponse(boolean valid, FailureReason failureReason,
|
||||
Set<String> roles, Set<String> authorities) {
|
||||
public ValidationResponse {
|
||||
roles = roles == null ? Set.of() : Set.copyOf(roles);
|
||||
authorities = authorities == null ? Set.of() : Set.copyOf(authorities);
|
||||
}
|
||||
public static ValidationResponse accepted(Set<String> roles, Set<String> authorities) {
|
||||
return new ValidationResponse(true, null, roles, authorities);
|
||||
}
|
||||
public static ValidationResponse rejected(FailureReason reason) {
|
||||
return new ValidationResponse(false, reason);
|
||||
return new ValidationResponse(false, reason, Set.of(), Set.of());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+28
-8
@@ -19,6 +19,7 @@ import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class LastlegSecurityProjectionService {
|
||||
@@ -103,7 +104,7 @@ public class LastlegSecurityProjectionService {
|
||||
requireStatus(value.status());
|
||||
yield new ProjectionInput("USER", String.valueOf(value.authUserId()), value.tenantId(),
|
||||
value.status(), value.permissionVersion(), envelope.aggregateVersion(),
|
||||
payloadHash, envelope.eventId(), value.changedAt());
|
||||
payloadHash, envelope.eventId(), value.changedAt(), Set.of(), Set.of());
|
||||
}
|
||||
case EventTypes.IAM_TENANT_STATUS_CHANGED -> {
|
||||
var value = objectMapper.treeToValue(envelope.payload(),
|
||||
@@ -114,7 +115,7 @@ public class LastlegSecurityProjectionService {
|
||||
requireStatus(value.status());
|
||||
yield new ProjectionInput("TENANT", String.valueOf(value.tenantId()), value.tenantId(),
|
||||
value.status(), null, envelope.aggregateVersion(), payloadHash,
|
||||
envelope.eventId(), value.changedAt());
|
||||
envelope.eventId(), value.changedAt(), Set.of(), Set.of());
|
||||
}
|
||||
case EventTypes.IAM_PERMISSION_VERSION_CHANGED -> {
|
||||
var value = objectMapper.treeToValue(envelope.payload(),
|
||||
@@ -122,9 +123,10 @@ public class LastlegSecurityProjectionService {
|
||||
requireIdentity(envelope, "AUTHORIZATION", value.authUserId(), value.tenantId());
|
||||
requirePayload(value.appCode(), value.changedAt(), value.permissionVersion(),
|
||||
envelope.aggregateVersion());
|
||||
requireAuthorizationSnapshot(value.roles(), value.authorities());
|
||||
yield new ProjectionInput("AUTHORIZATION", String.valueOf(value.authUserId()), value.tenantId(),
|
||||
null, value.permissionVersion(), envelope.aggregateVersion(), payloadHash,
|
||||
envelope.eventId(), value.changedAt());
|
||||
envelope.eventId(), value.changedAt(), value.roles(), value.authorities());
|
||||
}
|
||||
default -> throw new IllegalArgumentException("unsupported type");
|
||||
};
|
||||
@@ -158,6 +160,14 @@ public class LastlegSecurityProjectionService {
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireAuthorizationSnapshot(Set<String> roles, Set<String> authorities) {
|
||||
if (roles == null || authorities == null
|
||||
|| roles.stream().anyMatch(value -> value == null || value.isBlank())
|
||||
|| authorities.stream().anyMatch(value -> value == null || value.isBlank())) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "IAM 海外授权快照无效");
|
||||
}
|
||||
}
|
||||
|
||||
private ProjectionRow findForUpdate(String type, String id) {
|
||||
List<ProjectionRow> rows = jdbc.query(
|
||||
"SELECT aggregate_version,payload_hash FROM ll_security_projection "
|
||||
@@ -167,15 +177,24 @@ public class LastlegSecurityProjectionService {
|
||||
}
|
||||
|
||||
private void upsert(ProjectionInput value) {
|
||||
final String rolesJson;
|
||||
final String authoritiesJson;
|
||||
try {
|
||||
rolesJson = objectMapper.writeValueAsString(value.roles());
|
||||
authoritiesJson = objectMapper.writeValueAsString(value.authorities());
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("海外授权快照序列化失败", exception);
|
||||
}
|
||||
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),"
|
||||
+ "permission_version,roles_json,authorities_json,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),"
|
||||
+ "roles_json=VALUES(roles_json),authorities_json=VALUES(authorities_json),"
|
||||
+ "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());
|
||||
value.permissionVersion(), rolesJson, authoritiesJson, value.aggregateVersion(),
|
||||
value.payloadHash(), value.eventId(), value.changedAt());
|
||||
}
|
||||
|
||||
private record InboxRow(String eventType, String payloadHash, String status) { }
|
||||
@@ -184,5 +203,6 @@ public class LastlegSecurityProjectionService {
|
||||
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) { }
|
||||
String eventId, Instant changedAt, Set<String> roles,
|
||||
Set<String> authorities) { }
|
||||
}
|
||||
|
||||
+28
-6
@@ -3,17 +3,22 @@ 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 com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class LastlegSecurityValidationService {
|
||||
private final JdbcTemplate jdbc;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public LastlegSecurityValidationService(JdbcTemplate jdbc) {
|
||||
public LastlegSecurityValidationService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
|
||||
this.jdbc = jdbc;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public ValidationResponse validate(ValidationRequest request) {
|
||||
@@ -34,10 +39,10 @@ public class LastlegSecurityValidationService {
|
||||
}
|
||||
}
|
||||
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());
|
||||
"SELECT permission_version FROM ll_security_projection "
|
||||
+ "WHERE aggregate_type='AUTHORIZATION' AND aggregate_id=? AND tenant_id=?",
|
||||
(rs, row) -> rs.getLong(1),
|
||||
String.valueOf(request.userId()), request.tenantId());
|
||||
if (projectedVersions.isEmpty()) {
|
||||
return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING);
|
||||
}
|
||||
@@ -48,7 +53,19 @@ public class LastlegSecurityValidationService {
|
||||
if (request.permissionVersion() > projectedVersion) {
|
||||
return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_NOT_READY);
|
||||
}
|
||||
return ValidationResponse.accepted();
|
||||
List<String> snapshots = jdbc.query(
|
||||
"SELECT CONCAT(roles_json, CHAR(10), authorities_json) FROM ll_security_projection "
|
||||
+ "WHERE aggregate_type='AUTHORIZATION' AND aggregate_id=? AND tenant_id=?",
|
||||
(rs, row) -> rs.getString(1), String.valueOf(request.userId()), request.tenantId());
|
||||
if (snapshots.isEmpty() || snapshots.get(0) == null || !snapshots.get(0).contains("\n")) {
|
||||
return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING);
|
||||
}
|
||||
try {
|
||||
String[] values = snapshots.get(0).split("\n", 2);
|
||||
return ValidationResponse.accepted(readSet(values[0]), readSet(values[1]));
|
||||
} catch (Exception exception) {
|
||||
return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING);
|
||||
}
|
||||
}
|
||||
|
||||
private String status(String type, String id, long tenantId) {
|
||||
@@ -57,4 +74,9 @@ public class LastlegSecurityValidationService {
|
||||
(rs, row) -> rs.getString(1), type, id, tenantId);
|
||||
return values.isEmpty() ? null : values.get(0);
|
||||
}
|
||||
|
||||
private Set<String> readSet(String json) throws Exception {
|
||||
if (json == null || json.isBlank()) return Set.of();
|
||||
return objectMapper.readValue(json, new TypeReference<Set<String>>() { });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ baiyue:
|
||||
security:
|
||||
internal-service-token: ${INTERNAL_SERVICE_TOKEN:}
|
||||
jwt-mode: jwks
|
||||
session-mode: claims
|
||||
session-mode: gateway
|
||||
issuer: ${JWT_ISSUER:auth-service}
|
||||
audience: ${JWT_AUDIENCE:lastleg-api}
|
||||
jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}
|
||||
|
||||
@@ -1871,6 +1871,8 @@ CREATE TABLE IF NOT EXISTS ll_security_projection (
|
||||
tenant_id BIGINT NOT NULL COMMENT '平台0或客户ID',
|
||||
security_status VARCHAR(24) NULL COMMENT '当前安全状态:ACTIVE或DISABLED',
|
||||
permission_version BIGINT NULL COMMENT '当前单调权限版本',
|
||||
roles_json JSON NOT NULL COMMENT 'IAM投影的当前角色快照',
|
||||
authorities_json JSON NOT NULL COMMENT 'IAM投影的当前海外权限快照',
|
||||
aggregate_version BIGINT NOT NULL COMMENT '事件聚合单调版本',
|
||||
payload_hash CHAR(64) NOT NULL COMMENT '当前版本载荷哈希',
|
||||
last_event_id VARCHAR(64) NOT NULL COMMENT '最后应用的事件ID',
|
||||
@@ -1883,12 +1885,12 @@ CREATE TABLE IF NOT EXISTS ll_security_projection (
|
||||
|
||||
-- 开发基线固定管理员;后续真实 IAM 事件从 aggregate_version=1 单调覆盖该 version=0 投影。
|
||||
INSERT IGNORE INTO ll_security_projection (
|
||||
aggregate_type,aggregate_id,tenant_id,security_status,permission_version,aggregate_version,
|
||||
aggregate_type,aggregate_id,tenant_id,security_status,permission_version,roles_json,authorities_json,aggregate_version,
|
||||
payload_hash,last_event_id,changed_at,updated_at
|
||||
) VALUES
|
||||
('USER','1000001',0,'ACTIVE',0,0,SHA2('lastleg-baseline:platform_admin:USER:0',256),
|
||||
('USER','1000001',0,'ACTIVE',0,JSON_ARRAY(),JSON_ARRAY(),0,SHA2('lastleg-baseline:platform_admin:USER:0',256),
|
||||
'baseline-platform-admin-user-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'),
|
||||
('AUTHORIZATION','1000001',0,NULL,0,0,SHA2('lastleg-baseline:platform_admin:AUTHORIZATION:0',256),
|
||||
('AUTHORIZATION','1000001',0,NULL,0,JSON_ARRAY(),JSON_ARRAY(),0,SHA2('lastleg-baseline:platform_admin:AUTHORIZATION:0',256),
|
||||
'baseline-platform-admin-authorization-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000');
|
||||
|
||||
|
||||
|
||||
+4
-4
@@ -21,10 +21,10 @@ class LastlegSecurityContractTest {
|
||||
"PRIMARY KEY (event_id)", "payload_hash CHAR(64)",
|
||||
"CREATE TABLE IF NOT EXISTS ll_security_projection",
|
||||
"PRIMARY KEY (aggregate_type,aggregate_id)", "aggregate_version BIGINT",
|
||||
"permission_version BIGINT");
|
||||
"permission_version BIGINT", "roles_json JSON", "authorities_json JSON");
|
||||
for (String sql : new String[]{baseline, migration}) {
|
||||
assertThat(sql).contains("('USER','1000001',0,'ACTIVE',0,0",
|
||||
"('AUTHORIZATION','1000001',0,NULL,0,0",
|
||||
assertThat(sql).contains("('USER','1000001',0,'ACTIVE',0,JSON_ARRAY(),JSON_ARRAY(),0",
|
||||
"('AUTHORIZATION','1000001',0,NULL,0,JSON_ARRAY(),JSON_ARRAY(),0",
|
||||
"baseline-platform-admin-user-v0",
|
||||
"baseline-platform-admin-authorization-v0")
|
||||
.doesNotContain("baseline-merchant-admin");
|
||||
@@ -39,7 +39,7 @@ class LastlegSecurityContractTest {
|
||||
assertThat(service).contains("EventPayloadHash.matches", "validatedStorageHash")
|
||||
.doesNotContain("LoadingCompletedConsumer");
|
||||
assertThat(mirror).contains("TransactionPhase.AFTER_COMMIT", "Redis 镜像写入失败");
|
||||
assertThat(validation).contains("SELECT MAX(permission_version)", "tenant_id=?",
|
||||
assertThat(validation).contains("SELECT permission_version", "authorities_json", "tenant_id=?",
|
||||
"SECURITY_PROJECTION_MISSING", "SECURITY_PROJECTION_NOT_READY", "USER_DISABLED",
|
||||
"TENANT_DISABLED", "PERMISSION_VERSION_STALE");
|
||||
assertThat(validationDto).contains("Long userId", "Long tenantId", "Long permissionVersion")
|
||||
|
||||
+3
-1
@@ -43,7 +43,9 @@ class LastlegSecurityProjectionTransportTest {
|
||||
|
||||
private EventEnvelope envelope() {
|
||||
var value = new IamSecurityEventsV1.PermissionVersionChanged(
|
||||
101L, 0L, "PLATFORM", 3L, "contract", Instant.parse("2026-08-25T07:00:00Z"));
|
||||
101L, 0L, "PLATFORM", 3L, "contract", Instant.parse("2026-08-25T07:00:00Z"),
|
||||
java.util.Set.of("PLATFORM_ADMIN"),
|
||||
java.util.Set.of("platform:overseas-warehouse:last-mile-order:list"));
|
||||
var payload = objectMapper.valueToTree(value);
|
||||
return new EventEnvelope("00000000-0000-4000-8000-000000000091",
|
||||
EventTypes.IAM_PERMISSION_VERSION_CHANGED, IamSecurityEventsV1.VERSION,
|
||||
|
||||
+5
-3
@@ -5,6 +5,7 @@ import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -16,7 +17,7 @@ import static org.mockito.Mockito.mock;
|
||||
|
||||
class LastlegSecurityValidationServiceTest {
|
||||
private final JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
private final LastlegSecurityValidationService service = new LastlegSecurityValidationService(jdbc);
|
||||
private final LastlegSecurityValidationService service = new LastlegSecurityValidationService(jdbc, new ObjectMapper());
|
||||
|
||||
@Test void disabledUserFailsClosed() {
|
||||
doReturn(List.of("DISABLED")).when(jdbc)
|
||||
@@ -40,7 +41,8 @@ class LastlegSecurityValidationServiceTest {
|
||||
}
|
||||
|
||||
@Test void customerProjectionIsMandatoryButPlatformTenantDoesNotNeedOne() {
|
||||
doReturn(List.of("ACTIVE"), List.of(), List.of("ACTIVE"), List.of(8L)).when(jdbc)
|
||||
doReturn(List.of("ACTIVE"), List.of(), List.of("ACTIVE"), List.of(8L),
|
||||
List.of("[]\n[\"platform:overseas-warehouse:last-mile-order:list\"]")).when(jdbc)
|
||||
.query(anyString(), any(RowMapper.class), any(Object[].class));
|
||||
assertThat(service.validate(new ValidationRequest(42L, 100L, 8L)).failureReason())
|
||||
.isEqualTo(FailureReason.SECURITY_PROJECTION_MISSING);
|
||||
@@ -49,7 +51,7 @@ class LastlegSecurityValidationServiceTest {
|
||||
|
||||
@Test void permissionVersionMustExactlyMatchReliableProjection() {
|
||||
doReturn(List.of("ACTIVE"), List.of(9L), List.of("ACTIVE"), List.of(9L),
|
||||
List.of("ACTIVE"), List.of(9L)).when(jdbc)
|
||||
List.of("ACTIVE"), List.of(9L), List.of("[]\n[]")).when(jdbc)
|
||||
.query(anyString(), any(RowMapper.class), any(Object[].class));
|
||||
assertThat(service.validate(new ValidationRequest(42L, 0L, 8L)).failureReason())
|
||||
.isEqualTo(FailureReason.PERMISSION_VERSION_STALE);
|
||||
|
||||
Reference in New Issue
Block a user