From 496388902404f23579275b54d46967dd2e206872 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 7 Sep 2026 11:37:06 +0800 Subject: [PATCH] fix: project overseas authorization outside jwt --- auth/AGENTS.md | 4 +- .../auth/service/OverseasTokenService.java | 21 ------- .../service/OverseasTokenServiceTest.java | 6 +- .../common/event/IamSecurityEventsV1.java | 9 ++- .../security/AccessTokenSessionValidator.java | 5 ++ .../BaiyueSecurityAutoConfiguration.java | 2 + .../GatewayAccessTokenSessionValidator.java | 57 +++++++++++++++++++ .../security/JwtAuthenticationFilter.java | 2 +- ...atewayAccessTokenSessionValidatorTest.java | 46 +++++++++++++++ config/nacos/local/lastleg-service.yml | 2 +- .../local/warehouse-overseas-service.yml | 2 +- deploy/sql/海外区域统一增量-MySQL8.sql | 22 ++++++- gateway-overseas/AGENTS.md | 6 +- .../config/RequiredClaimsJwtValidator.java | 8 +-- .../HttpOverseasSecurityValidationClient.java | 15 ++++- .../OverseasSecurityProjectionFilter.java | 21 ++++++- .../OverseasSecurityValidationClient.java | 8 ++- .../filter/TrustedIdentityHeaderFilter.java | 4 +- .../RequiredClaimsJwtValidatorTest.java | 6 +- .../OverseasSecurityProjectionFilterTest.java | 28 ++++++--- .../iam/mapper/AuthorizationQueryMapper.java | 36 ++++++++++++ .../service/SecurityEventOutboxService.java | 16 +++++- .../service/SecurityEventOutboxRelayTest.java | 4 +- .../SecurityEventOutboxServiceTest.java | 28 +++++++++ .../security/dto/LastlegSecurityDtos.java | 14 ++++- .../LastlegSecurityProjectionService.java | 36 +++++++++--- .../LastlegSecurityValidationService.java | 34 +++++++++-- .../src/main/resources/application.yml | 2 +- .../src/main/resources/db/lastleg.sql | 8 ++- .../security/LastlegSecurityContractTest.java | 8 +-- ...astlegSecurityProjectionTransportTest.java | 4 +- .../LastlegSecurityValidationServiceTest.java | 8 ++- .../src/main/resources/application.yml | 2 +- 33 files changed, 382 insertions(+), 92 deletions(-) create mode 100644 common-security/src/main/java/com/baiyue/common/security/GatewayAccessTokenSessionValidator.java create mode 100644 common-security/src/test/java/com/baiyue/common/security/GatewayAccessTokenSessionValidatorTest.java diff --git a/auth/AGENTS.md b/auth/AGENTS.md index 94dfa7685..0cb3466c8 100644 --- a/auth/AGENTS.md +++ b/auth/AGENTS.md @@ -17,7 +17,7 @@ | 登录标识与企业联系人解耦 | `service/AccountManagementService.java`、`dto/AccountManagementDtos.java`、`service/MerchantRegistrationService.java`;注册手机号只在创建时同时初始化首位管理员登录手机号和公司联系电话,创建后二者独立维护;账号手机号变更需撤销该账号全部会话 | | 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`;Auth 是国内与海外仓共用的唯一用户 Token 签发方,JWKS 公钥发布按认证接口契约实现 | | 国内小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,国内服务结合 Redis 会话校验 | -| 海外访问 Token 签发 | `service/OverseasTokenService.java`;登录/刷新编排使用同一用户身份同时签发短时 RS256 海外 Token,限定海外 Audience,只保留平台海外仓/海外业务/海外主数据、客户海外仓 `merchant:warehouse:*`、海外财务 API 所需的 `platform:finance:*` 及 `lastleg:*` 权限前缀;前端统一认证层负责按区域 API 自动选择 | +| 海外访问 Token 签发 | `service/OverseasTokenService.java`;登录/刷新编排使用同一用户身份同时签发短时 RS256 海外 Token,限定海外 Audience,只携带稳定身份、客户范围和版本声明,不携带角色或权限集;前端统一认证层负责按区域 API 自动选择 | | 海外 PDA 实时会话校验 | `controller/InternalAccountController.java`、`service/OverseasSessionValidationService.java`;只接受海外 Gateway 已验签后提取的 userId/tokenVersion/overseasWarehouseId,以内部服务令牌保护并实时联查 IAM 当前仓绑定 | | Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` | | 注册协作 | `service/MerchantRegistrationService.java` | @@ -28,7 +28,7 @@ ## 修改约束 -- 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。国内登录 Token 的实时权限来自 Redis 会话快照;登录/刷新同时签发短时海外 Token并携带必要权限 Claims,海外服务不访问国内 Redis。修改登录响应、Claim、Audience、JWKS、权限快照或过期策略时联查 Admin/Client 统一认证层、`common-security/`、`gateway/`、`iam/` 和海外服务消费者。 +- 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。国内登录 Token 的实时权限来自 Redis 会话快照;短时海外 Token 不携带角色或权限集,海外授权由 IAM 事件投影到 Lastleg,再由海外 Gateway 验证并以可信内网上下文传递,海外服务不访问国内 Redis。修改登录响应、Claim、Audience、JWKS、权限快照或过期策略时联查 Admin/Client 统一认证层、`common-security/`、`gateway/`、`iam/` 和海外服务消费者。 - 注册跨 Auth/IAM 必须保持幂等和失败补偿;内部接口仅接受服务令牌。 - 登录账号的 username、phone、email 唯一性只约束 Auth 账号;IAM 公司联系电话/联系邮箱是企业资料,不得反向覆盖账号登录标识,也不得因其他企业使用相同联系电话而拒绝。成员登录手机号变更只能由 IAM 账号协作入口同步并撤销该账号全部会话。 - 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。 diff --git a/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java b/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java index ea12d933f..13a13cd35 100644 --- a/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java +++ b/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java @@ -19,24 +19,12 @@ import org.springframework.stereotype.Service; import java.time.Instant; import java.util.List; -import java.util.Set; import java.util.UUID; -import java.util.stream.Collectors; /** 将已经通过国内 HMAC 与 Redis 校验的会话兑换为海外 RS256 短时令牌。 */ @Service @ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true") public class OverseasTokenService { - private static final List OVERSEAS_AUTHORITY_PREFIXES = List.of( - "platform:overseas-warehouse:", - "platform:overseas-business:", - "platform:overseas-master-data:", - "merchant:warehouse:", - "merchant:cross-border:", - "platform:finance:", - "lastleg:" - ); - @Resource private AuthAccountRepository accounts; @Resource private AuthorizationLoader authorizationLoader; @Resource private OverseasJwtProperties properties; @@ -61,8 +49,6 @@ public class OverseasTokenService { .claim("user_id", account.getId()).claim("tenant_id", authorization.getTenantId()) .claim("membership_id", authorization.getMembershipId()) .claim("app_code", authorization.getAppCode()).claim("client_type", authorization.getClientType()) - .claim("roles", authorization.getRoles()) - .claim("authorities", overseasAuthorities(authorization.getPermissions())) .claim("token_version", account.getTokenVersion() == null ? 0 : account.getTokenVersion()) .claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion()); if (authorization.getOverseasWarehouseId() != null) { @@ -74,12 +60,5 @@ public class OverseasTokenService { return new OverseasTokenResponse("Bearer", token, properties.accessTokenTtl().toSeconds()); } - private Set overseasAuthorities(Set permissions) { - if (permissions == null || permissions.isEmpty()) return Set.of(); - return permissions.stream() - .filter(permission -> permission != null && OVERSEAS_AUTHORITY_PREFIXES.stream().anyMatch(permission::startsWith)) - .collect(Collectors.toUnmodifiableSet()); - } - public record OverseasTokenResponse(String tokenType, String accessToken, long expiresIn) { } } diff --git a/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java index a925956cc..0978b29d3 100644 --- a/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java @@ -79,10 +79,8 @@ class OverseasTokenServiceTest { .containsEntry("permission_version", 7L) .containsEntry("token_version", 0) .containsEntry("overseas_warehouse_id", 900L) - .containsEntry("roles", Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR")) - .containsEntry("authorities", Set.of(authority, merchantAuthority, merchantCrossBorderAuthority, - lastlegAuthority, financeAuthority)) - .doesNotContainKeys("userId", "tenantId", "appCode", "permissionVersion"); + .doesNotContainKeys("roles", "authorities", "permissions", "userId", "tenantId", "appCode", + "permissionVersion"); assertThat(response.expiresIn()).isEqualTo(900); } diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/IamSecurityEventsV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/IamSecurityEventsV1.java index 3794f37ef..8d1251801 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/IamSecurityEventsV1.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/IamSecurityEventsV1.java @@ -1,6 +1,7 @@ package com.baiyue.common.event; import java.time.Instant; +import java.util.Set; /** IAM 向海外服务发布的 V1 安全投影事件负载。 */ public final class IamSecurityEventsV1 { @@ -14,5 +15,11 @@ public final class IamSecurityEventsV1 { public record TenantStatusChanged(long tenantId, String status, Instant changedAt) { } public record PermissionVersionChanged(long authUserId, long tenantId, String appCode, - long permissionVersion, String reason, Instant changedAt) { } + long permissionVersion, String reason, Instant changedAt, + Set roles, Set authorities) { + public PermissionVersionChanged { + roles = roles == null ? Set.of() : Set.copyOf(roles); + authorities = authorities == null ? Set.of() : Set.copyOf(authorities); + } + } } diff --git a/common-security/src/main/java/com/baiyue/common/security/AccessTokenSessionValidator.java b/common-security/src/main/java/com/baiyue/common/security/AccessTokenSessionValidator.java index e621e6f50..314d9ac53 100644 --- a/common-security/src/main/java/com/baiyue/common/security/AccessTokenSessionValidator.java +++ b/common-security/src/main/java/com/baiyue/common/security/AccessTokenSessionValidator.java @@ -1,6 +1,7 @@ package com.baiyue.common.security; import org.springframework.security.oauth2.jwt.Jwt; +import jakarta.servlet.http.HttpServletRequest; /** * 访问令牌会话有效性校验扩展点,用于在 JWT 校验之外确认服务端登录会话状态。 @@ -8,4 +9,8 @@ import org.springframework.security.oauth2.jwt.Jwt; @FunctionalInterface public interface AccessTokenSessionValidator { AccessTokenSession validate(Jwt jwt); + + default AccessTokenSession validate(Jwt jwt, HttpServletRequest request) { + return validate(jwt); + } } diff --git a/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java b/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java index e03486d95..3713986d1 100644 --- a/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java +++ b/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java @@ -50,9 +50,11 @@ public class BaiyueSecurityAutoConfiguration { org.springframework.beans.factory.ObjectProvider redis, ObjectMapper objectMapper, @Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix, + @Value("${baiyue.security.gateway-internal-service-token:${baiyue.security.internal-service-token:${INTERNAL_SERVICE_TOKEN:}}}") String gatewayToken, @Value("${baiyue.security.session-mode:redis}") String sessionMode) { AccessTokenSessionValidator validator; if ("claims".equalsIgnoreCase(sessionMode)) validator = new ClaimsAccessTokenSessionValidator(); + else if ("gateway".equalsIgnoreCase(sessionMode)) validator = new GatewayAccessTokenSessionValidator(gatewayToken); else { StringRedisTemplate template=redis.getIfAvailable(); if(template==null) throw new IllegalStateException("session-mode=redis requires StringRedisTemplate"); diff --git a/common-security/src/main/java/com/baiyue/common/security/GatewayAccessTokenSessionValidator.java b/common-security/src/main/java/com/baiyue/common/security/GatewayAccessTokenSessionValidator.java new file mode 100644 index 000000000..2c749dd9b --- /dev/null +++ b/common-security/src/main/java/com/baiyue/common/security/GatewayAccessTokenSessionValidator.java @@ -0,0 +1,57 @@ +package com.baiyue.common.security; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.security.oauth2.jwt.Jwt; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.LinkedHashSet; +import java.util.Set; + +/** Uses the overseas gateway's authenticated authorization snapshot instead of large JWT claims. */ +public final class GatewayAccessTokenSessionValidator implements AccessTokenSessionValidator { + public static final String TOKEN_HEADER = "X-Gateway-Internal-Token"; + public static final String ROLES_HEADER = "X-Overseas-Roles"; + public static final String AUTHORITIES_HEADER = "X-Overseas-Authorities"; + + private final byte[] expectedToken; + + public GatewayAccessTokenSessionValidator(String expectedToken) { + if (expectedToken == null || expectedToken.isBlank()) { + throw new IllegalStateException("session-mode=gateway requires an internal service token"); + } + this.expectedToken = expectedToken.getBytes(StandardCharsets.UTF_8); + } + + @Override + public AccessTokenSession validate(Jwt jwt) { + throw new IllegalArgumentException("Gateway authorization headers are required"); + } + + @Override + public AccessTokenSession validate(Jwt jwt, HttpServletRequest request) { + String supplied = request.getHeader(TOKEN_HEADER); + if (supplied == null || !MessageDigest.isEqual(expectedToken, supplied.getBytes(StandardCharsets.UTF_8))) { + throw new IllegalArgumentException("Untrusted overseas gateway authorization context"); + } + Long userId = number(jwt.getClaims().getOrDefault("userId", jwt.getClaims().get("user_id"))); + return new AccessTokenSession(1, userId, values(request.getHeader(ROLES_HEADER)), + values(request.getHeader(AUTHORITIES_HEADER))); + } + + private static Long number(Object value) { + if (value instanceof Number number) return number.longValue(); + try { return value == null ? null : Long.valueOf(value.toString()); } + catch (NumberFormatException exception) { return null; } + } + + private static Set values(String header) { + if (header == null || header.isBlank()) return Set.of(); + LinkedHashSet values = new LinkedHashSet<>(); + for (String value : header.split(",")) { + String normalized = value.trim(); + if (!normalized.isEmpty()) values.add(normalized); + } + return Set.copyOf(values); + } +} diff --git a/common-security/src/main/java/com/baiyue/common/security/JwtAuthenticationFilter.java b/common-security/src/main/java/com/baiyue/common/security/JwtAuthenticationFilter.java index d77643366..ea503efbf 100644 --- a/common-security/src/main/java/com/baiyue/common/security/JwtAuthenticationFilter.java +++ b/common-security/src/main/java/com/baiyue/common/security/JwtAuthenticationFilter.java @@ -55,7 +55,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { String tokenValue = header.substring(BEARER.length()).trim(); try { Jwt jwt = jwtDecoder.decode(tokenValue); - AccessTokenSession session = sessionValidator.validate(jwt); + AccessTokenSession session = sessionValidator.validate(jwt, request); Authentication authentication = claimsMapper.map(jwt, session); SecurityContextHolder.getContext().setAuthentication(authentication); filterChain.doFilter(request, response); diff --git a/common-security/src/test/java/com/baiyue/common/security/GatewayAccessTokenSessionValidatorTest.java b/common-security/src/test/java/com/baiyue/common/security/GatewayAccessTokenSessionValidatorTest.java new file mode 100644 index 000000000..192e7ff97 --- /dev/null +++ b/common-security/src/test/java/com/baiyue/common/security/GatewayAccessTokenSessionValidatorTest.java @@ -0,0 +1,46 @@ +package com.baiyue.common.security; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.oauth2.jwt.Jwt; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class GatewayAccessTokenSessionValidatorTest { + private final GatewayAccessTokenSessionValidator validator = + new GatewayAccessTokenSessionValidator("gateway-secret"); + + @Test void acceptsOnlyGatewayAuthenticatedAuthorizationSnapshot() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(GatewayAccessTokenSessionValidator.TOKEN_HEADER, "gateway-secret"); + request.addHeader(GatewayAccessTokenSessionValidator.ROLES_HEADER, "PLATFORM_ADMIN"); + request.addHeader(GatewayAccessTokenSessionValidator.AUTHORITIES_HEADER, + "platform:overseas-warehouse:last-mile-order:list,platform:finance:fee-source:view"); + + AccessTokenSession session = validator.validate(jwt(), request); + + assertThat(session.userId()).isEqualTo(42L); + assertThat(session.roles()).containsExactly("PLATFORM_ADMIN"); + assertThat(session.permissions()).containsExactlyInAnyOrder( + "platform:overseas-warehouse:last-mile-order:list", "platform:finance:fee-source:view"); + } + + @Test void rejectsForgedOrMissingGatewayToken() { + MockHttpServletRequest forged = new MockHttpServletRequest(); + forged.addHeader(GatewayAccessTokenSessionValidator.TOKEN_HEADER, "forged"); + forged.addHeader(GatewayAccessTokenSessionValidator.AUTHORITIES_HEADER, + "platform:overseas-warehouse:last-mile-order:list"); + assertThatThrownBy(() -> validator.validate(jwt(), forged)) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("Untrusted"); + } + + private Jwt jwt() { + return Jwt.withTokenValue("small-overseas-token").header("alg", "RS256").subject("42") + .issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300)) + .claim("user_id", 42L).claim("tenant_id", 0L).claim("app_code", "PLATFORM") + .claim("permission_version", 7L).claim("token_version", 1).build(); + } +} diff --git a/config/nacos/local/lastleg-service.yml b/config/nacos/local/lastleg-service.yml index 259c7e72e..77ec1e8b2 100644 --- a/config/nacos/local/lastleg-service.yml +++ b/config/nacos/local/lastleg-service.yml @@ -23,7 +23,7 @@ baiyue: security: internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} 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} diff --git a/config/nacos/local/warehouse-overseas-service.yml b/config/nacos/local/warehouse-overseas-service.yml index 685994b87..7edeaec76 100644 --- a/config/nacos/local/warehouse-overseas-service.yml +++ b/config/nacos/local/warehouse-overseas-service.yml @@ -13,7 +13,7 @@ baiyue: token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} security: 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} diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index b076f5a4a..534e3deb8 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -1607,6 +1607,8 @@ CREATE TABLE IF NOT EXISTS ll_security_projection ( 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 '当前单调权限版本', + 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', changed_at DATETIME(3) NOT NULL COMMENT '国内IAM事实发生时间', updated_at DATETIME(3) NOT NULL COMMENT '投影更新时间', @@ -1614,14 +1616,28 @@ CREATE TABLE IF NOT EXISTS ll_security_projection ( KEY idx_security_projection_permission (aggregate_id,permission_version) ) ENGINE=InnoDB COMMENT='海外IAM安全状态与权限版本投影'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_security_projection' AND column_name='roles_json'), 'SELECT 1', + 'ALTER TABLE ll_security_projection ADD COLUMN roles_json JSON NULL COMMENT ''IAM投影的当前角色快照'' AFTER permission_version')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_security_projection' AND column_name='authorities_json'), 'SELECT 1', + 'ALTER TABLE ll_security_projection ADD COLUMN authorities_json JSON NULL COMMENT ''IAM投影的当前海外权限快照'' AFTER roles_json')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +UPDATE ll_security_projection SET roles_json=JSON_ARRAY() WHERE roles_json IS NULL; +UPDATE ll_security_projection SET authorities_json=JSON_ARRAY() WHERE authorities_json IS NULL; +ALTER TABLE ll_security_projection + MODIFY COLUMN roles_json JSON NOT NULL COMMENT 'IAM投影的当前角色快照', + MODIFY COLUMN authorities_json JSON NOT NULL COMMENT 'IAM投影的当前海外权限快照'; + -- 与国内固定 auth_account.id=1000001、tenant_id=0 的 platform_admin 一致;真实事件从版本1覆盖。 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'); DROP PROCEDURE IF EXISTS assert_lastleg_security_projection_ready; diff --git a/gateway-overseas/AGENTS.md b/gateway-overseas/AGENTS.md index ddd4f2c28..94e97063a 100644 --- a/gateway-overseas/AGENTS.md +++ b/gateway-overseas/AGENTS.md @@ -14,9 +14,9 @@ | Audience 校验 | `config/AudienceJwtValidator.java` | | 必要 Claims 校验 | `config/RequiredClaimsJwtValidator.java` | | PDA 换仓即时失效 | `filter/PdaSessionValidationFilter.java`、`HttpPdaSessionValidationClient.java`、`config/PdaSessionValidationProperties.java`;仅 `/api/pda/**` 本地验签后同步核对 Auth,任何异常 fail-closed | -| 海外安全事件即时失效 | `filter/OverseasSecurityProjectionFilter.java`、`HttpOverseasSecurityValidationClient.java`;全部 `/api/**` 本地验签后核对海外安全投影,用户/客户停用与权限版本落后立即拒绝 | +| 海外安全事件即时失效 | `filter/OverseasSecurityProjectionFilter.java`、`HttpOverseasSecurityValidationClient.java`;全部 `/api/**` 本地验签后核对海外安全投影,用户/客户停用与权限版本不一致立即拒绝,并取回当前角色/权限快照 | | 安全配置项 | `config/GatewaySecurityProperties.java` | -| 可信身份头传递 | `filter/TrustedIdentityHeaderFilter.java` | +| 可信身份与授权头传递 | `filter/TrustedIdentityHeaderFilter.java`、`filter/OverseasSecurityProjectionFilter.java`;先清除外部伪造头,再以内部服务令牌保护投影快照 | | 环境配置 | `src/main/resources/application.yml`、`../config/nacos/` | | 浏览器业务路由 | `src/main/resources/application-overseas.yml`;Lastleg 显式覆盖 `/api/merchant/v1/last-leg/**`、`/api/platform/v1/last-leg/**`,Warehouse 显式覆盖商户仓库与平台 `warehouse-admin` 路径 | | 海外 PDA 路由 | 设备端 `../../apps/pda-overseas-uniapp/`,服务端入口 `/api/pda/v1/last-leg/**`;必须在 Gateway 路由中显式声明并保持海外 Token/权限校验,不得让设备直连 Lastleg | @@ -25,7 +25,7 @@ ## 安全规则 - 生产令牌 `aud` 必须包含 `lastleg-api`,并包含配置要求的身份声明。 -- 网关必须清除外部请求伪造的可信身份头,仅传递验签后生成的身份信息。 +- 网关必须清除外请求伪造的可信身份与授权头,仅传递验签并核对安全投影后生成的上下文;业务服务必须验证内部服务令牌。 - JWKS 地址、Issuer 和 Audience 必须外部配置;不得在源码写死生产密钥。 - merchant、platform 与 PDA 路径分别显式维护路由和授权边界;新增 PDA Controller 不会自动成为公网路由,Gateway 未声明前不得把设备链路标记为已联通。 diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/RequiredClaimsJwtValidator.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/RequiredClaimsJwtValidator.java index 35ff9d210..4135c50d0 100644 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/config/RequiredClaimsJwtValidator.java +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/config/RequiredClaimsJwtValidator.java @@ -5,14 +5,13 @@ import org.springframework.security.oauth2.core.OAuth2TokenValidator; import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; import org.springframework.security.oauth2.jwt.Jwt; -import java.util.Collection; import java.util.List; import java.util.Locale; /** Validates the identity and authorization claims required by overseas APIs. */ final class RequiredClaimsJwtValidator implements OAuth2TokenValidator { private static final List REQUIRED_CLAIMS = List.of( - "sub", "tenant_id", "app_code", "token_version", "permission_version", "authorities"); + "sub", "tenant_id", "app_code", "token_version", "permission_version"); @Override public OAuth2TokenValidatorResult validate(Jwt jwt) { @@ -44,11 +43,6 @@ final class RequiredClaimsJwtValidator implements OAuth2TokenValidator { && !("MERCHANT".equals(appCode) && tenantId > 0L)) { return failure("JWT app_code 与 tenant_id 访问域不一致"); } - Object authorities = jwt.getClaims().get("authorities"); - if (!(authorities instanceof Collection values) - || values.stream().anyMatch(value -> value == null || value.toString().isBlank())) { - return failure("JWT authorities 必须是权限列表"); - } return OAuth2TokenValidatorResult.success(); } diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java index 17f3af48b..5adcb9b12 100644 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java @@ -6,6 +6,8 @@ import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Mono; +import java.util.LinkedHashSet; +import java.util.Set; @Component public class HttpOverseasSecurityValidationClient implements OverseasSecurityValidationClient { @@ -29,9 +31,20 @@ public class HttpOverseasSecurityValidationClient implements OverseasSecurityVal return Mono.error(new IllegalStateException("海外安全投影响应无效")); } return Mono.just(new Result(data.path("valid").asBoolean(false), - data.path("failureReason").isNull() ? null : data.path("failureReason").asText(null))); + data.path("failureReason").isNull() ? null : data.path("failureReason").asText(null), + strings(data.path("roles")), strings(data.path("authorities")))); }); } + private static Set strings(JsonNode values) { + if (values == null || !values.isArray()) return Set.of(); + LinkedHashSet result = new LinkedHashSet<>(); + values.forEach(value -> { + String text = value.asText(""); + if (!text.isBlank()) result.add(text); + }); + return Set.copyOf(result); + } + private record Request(long userId, long tenantId, long permissionVersion) { } } diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java index 91fc516cb..43ee910a1 100644 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java @@ -1,6 +1,7 @@ package com.baiyue.gateway.filter; import com.baiyue.gateway.config.GatewaySecurityConfig; +import com.baiyue.gateway.config.OverseasSecurityProjectionProperties; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.cloud.gateway.filter.GatewayFilterChain; import org.springframework.cloud.gateway.filter.GlobalFilter; @@ -11,15 +12,20 @@ import org.springframework.security.oauth2.server.resource.authentication.JwtAut import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; +import java.util.Collection; +import java.util.stream.Collectors; @Component public class OverseasSecurityProjectionFilter implements GlobalFilter, Ordered { private final OverseasSecurityValidationClient client; private final ObjectMapper objectMapper; + private final OverseasSecurityProjectionProperties properties; - public OverseasSecurityProjectionFilter(OverseasSecurityValidationClient client, ObjectMapper objectMapper) { + public OverseasSecurityProjectionFilter(OverseasSecurityValidationClient client, ObjectMapper objectMapper, + OverseasSecurityProjectionProperties properties) { this.client = client; this.objectMapper = objectMapper; + this.properties = properties; } @Override @@ -45,7 +51,14 @@ public class OverseasSecurityProjectionFilter implements GlobalFilter, Ordered { return client.validate(userId, tenantId, permissionVersion) .switchIfEmpty(Mono.error(new SecurityProjectionFailure("A0401", "海外安全状态校验不可用"))) .flatMap(result -> { - if (result.valid()) return chain.filter(exchange); + if (result.valid()) { + var request = exchange.getRequest().mutate() + .header("X-Overseas-Roles", join(result.roles())) + .header("X-Overseas-Authorities", join(result.authorities())) + .header("X-Gateway-Internal-Token", properties.internalServiceToken()) + .build(); + return chain.filter(exchange.mutate().request(request).build()); + } if ("USER_DISABLED".equals(result.failureReason())) { return Mono.error(new SecurityProjectionFailure("A0401", "账号已停用")); } @@ -64,6 +77,10 @@ public class OverseasSecurityProjectionFilter implements GlobalFilter, Ordered { catch (NumberFormatException exception) { return null; } } + private static String join(Collection values) { + return values.stream().sorted().collect(Collectors.joining(",")); + } + @Override public int getOrder() { return -95; } private static final class SecurityProjectionFailure extends RuntimeException { diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java index a267cb233..a7ae9b3cd 100644 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java @@ -1,8 +1,14 @@ package com.baiyue.gateway.filter; import reactor.core.publisher.Mono; +import java.util.Set; public interface OverseasSecurityValidationClient { Mono validate(long userId, long tenantId, long permissionVersion); - record Result(boolean valid, String failureReason) { } + record Result(boolean valid, String failureReason, Set roles, Set authorities) { + public Result { + roles = roles == null ? Set.of() : Set.copyOf(roles); + authorities = authorities == null ? Set.of() : Set.copyOf(authorities); + } + } } diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/TrustedIdentityHeaderFilter.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/TrustedIdentityHeaderFilter.java index 80b2b320b..ec1cbab64 100644 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/TrustedIdentityHeaderFilter.java +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/TrustedIdentityHeaderFilter.java @@ -18,7 +18,8 @@ import java.util.List; public class TrustedIdentityHeaderFilter implements GlobalFilter, Ordered { private static final List TRUSTED_HEADERS = List.of( "X-User-Id", "X-Membership-Id", "X-Tenant-Id", "X-Operator-Tenant-Id", - "X-App-Code", "X-Client-Type" + "X-App-Code", "X-Client-Type", "X-Overseas-Roles", "X-Overseas-Authorities", + "X-Gateway-Internal-Token" ); @Override @@ -70,4 +71,3 @@ public class TrustedIdentityHeaderFilter implements GlobalFilter, Ordered { @Override public String getName() { return "anonymous"; } } } - diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/RequiredClaimsJwtValidatorTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/RequiredClaimsJwtValidatorTest.java index fa9c07ea8..81b21ddb1 100644 --- a/gateway-overseas/src/test/java/com/baiyue/gateway/config/RequiredClaimsJwtValidatorTest.java +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/config/RequiredClaimsJwtValidatorTest.java @@ -26,19 +26,19 @@ class RequiredClaimsJwtValidatorTest { Jwt value = Jwt.withTokenValue("token").header("alg", "RS256").subject("1001") .issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300)) .claim("tenant_id", "0").claim("app_code", "PLATFORM") - .claim("permission_version", 3L).claim("authorities", List.of()) + .claim("permission_version", 3L) .build(); assertThat(validator.validate(value).hasErrors()).isTrue(); } @Test - void rejectsMissingAuthorities() { + void acceptsTokenWithoutAuthoritiesBecauseProjectionOwnsAuthorization() { Jwt value = Jwt.withTokenValue("token").header("alg", "RS256").subject("1001") .issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300)) .claim("tenant_id", "0").claim("app_code", "PLATFORM").claim("token_version", 1) .claim("permission_version", 3L) .build(); - assertThat(validator.validate(value).hasErrors()).isTrue(); + assertThat(validator.validate(value).hasErrors()).isFalse(); } @Test diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java index 1857158e8..0bb10bfe5 100644 --- a/gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java @@ -1,6 +1,7 @@ package com.baiyue.gateway.filter; import com.fasterxml.jackson.databind.ObjectMapper; +import com.baiyue.gateway.config.OverseasSecurityProjectionProperties; import org.junit.jupiter.api.Test; import org.springframework.http.HttpStatus; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; @@ -13,7 +14,9 @@ import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import java.time.Instant; +import java.time.Duration; import java.util.List; +import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; import static org.assertj.core.api.Assertions.assertThat; @@ -23,11 +26,15 @@ import static org.mockito.Mockito.when; class OverseasSecurityProjectionFilterTest { private final OverseasSecurityValidationClient client = mock(OverseasSecurityValidationClient.class); - private final OverseasSecurityProjectionFilter filter = new OverseasSecurityProjectionFilter(client, new ObjectMapper()); + private final OverseasSecurityProjectionProperties properties = + new OverseasSecurityProjectionProperties("http://lastleg", + "gateway-internal-test-token-32-bytes", Duration.ofSeconds(2)); + private final OverseasSecurityProjectionFilter filter = + new OverseasSecurityProjectionFilter(client, new ObjectMapper(), properties); @Test void disabledUserFailsWithA0401() { when(client.validate(42, 0, 8)).thenReturn(Mono.just( - new OverseasSecurityValidationClient.Result(false, "USER_DISABLED"))); + new OverseasSecurityValidationClient.Result(false, "USER_DISABLED", Set.of(), Set.of()))); var exchange = exchange("/api/platform/v1/last-leg/orders"); StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); @@ -37,7 +44,7 @@ class OverseasSecurityProjectionFilterTest { @Test void disabledTenantAndStalePermissionFailWithA0403() { for (String reason : new String[]{"TENANT_DISABLED", "PERMISSION_VERSION_STALE"}) { when(client.validate(42, 0, 8)).thenReturn(Mono.just( - new OverseasSecurityValidationClient.Result(false, reason))); + new OverseasSecurityValidationClient.Result(false, reason, Set.of(), Set.of()))); var exchange = exchange("/api/merchant/v1/last-leg/orders"); StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); @@ -54,15 +61,22 @@ class OverseasSecurityProjectionFilterTest { @Test void acceptedSecurityStateContinuesAndNonApiPathsDoNotCallProjection() { when(client.validate(42, 0, 8)).thenReturn(Mono.just( - new OverseasSecurityValidationClient.Result(true, null))); + new OverseasSecurityValidationClient.Result(true, null, Set.of("PLATFORM_ADMIN"), + Set.of("platform:overseas-warehouse:last-mile-order:list")))); AtomicBoolean routed = new AtomicBoolean(); - StepVerifier.create(filter.filter(exchange("/api/platform/v1/last-leg/orders"), ignored -> { - routed.set(true); return Mono.empty(); + var accepted = exchange("/api/platform/v1/last-leg/orders"); + StepVerifier.create(filter.filter(accepted, routedExchange -> { + routed.set(true); + assertThat(routedExchange.getRequest().getHeaders().getFirst("X-Gateway-Internal-Token")) + .isEqualTo("gateway-secret"); + assertThat(routedExchange.getRequest().getHeaders().getFirst("X-Overseas-Authorities")) + .isEqualTo("platform:overseas-warehouse:last-mile-order:list"); + return Mono.empty(); })).verifyComplete(); assertThat(routed).isTrue(); var isolatedClient = mock(OverseasSecurityValidationClient.class); - var isolatedFilter = new OverseasSecurityProjectionFilter(isolatedClient, new ObjectMapper()); + var isolatedFilter = new OverseasSecurityProjectionFilter(isolatedClient, new ObjectMapper(), properties); StepVerifier.create(isolatedFilter.filter(exchange("/actuator/health"), ignored -> Mono.empty())).verifyComplete(); verifyNoInteractions(isolatedClient); } diff --git a/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java b/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java index 6d57e9632..a403399f9 100644 --- a/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java +++ b/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java @@ -63,6 +63,42 @@ public interface AuthorizationQueryMapper { @Param("membershipId") long membershipId, @Param("appCode") String appCode); + @Select(""" + SELECT DISTINCT r.role_code + FROM iam_user u + JOIN iam_membership m ON m.user_id = u.id + JOIN iam_membership_role mr ON mr.membership_id = m.id AND mr.tenant_id = m.tenant_id + JOIN iam_role r ON r.id = mr.role_id + WHERE u.auth_user_id = #{userId} AND u.status = 'ACTIVE' AND u.deleted = 0 + AND m.tenant_id = #{tenantId} AND m.status = 'ACTIVE' AND m.deleted = 0 + AND (r.tenant_id = 0 OR r.tenant_id = #{tenantId}) + AND r.app_code = #{appCode} AND r.status = 'ACTIVE' AND r.deleted = 0 + ORDER BY r.role_code + """) + List findRolesByIdentity(@Param("userId") long userId, + @Param("tenantId") long tenantId, + @Param("appCode") String appCode); + + @Select(""" + SELECT DISTINCT p.permission_code + FROM iam_user u + JOIN iam_membership m ON m.user_id = u.id + JOIN iam_membership_role mr ON mr.membership_id = m.id AND mr.tenant_id = m.tenant_id + JOIN iam_role r ON r.id = mr.role_id + JOIN iam_role_permission rp ON rp.role_id = r.id + JOIN iam_permission p ON p.id = rp.permission_id + WHERE u.auth_user_id = #{userId} AND u.status = 'ACTIVE' AND u.deleted = 0 + AND m.tenant_id = #{tenantId} AND m.status = 'ACTIVE' AND m.deleted = 0 + AND (r.tenant_id = 0 OR r.tenant_id = #{tenantId}) + AND r.app_code = #{appCode} AND r.status = 'ACTIVE' AND r.deleted = 0 + AND (p.tenant_id = 0 OR p.tenant_id = #{tenantId}) + AND p.app_code = #{appCode} AND p.status = 'ACTIVE' AND p.deleted = 0 + ORDER BY p.permission_code + """) + List findPermissionsByIdentity(@Param("userId") long userId, + @Param("tenantId") long tenantId, + @Param("appCode") String appCode); + @Select("SELECT COALESCE(MAX(permission_version),0) FROM iam_security_subject_version WHERE auth_user_id=#{userId}") long findPermissionVersion(@Param("userId") long userId); diff --git a/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java index 520689a86..674748ef8 100644 --- a/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java +++ b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java @@ -7,6 +7,7 @@ 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.baiyue.iam.mapper.AuthorizationQueryMapper; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; @@ -14,12 +15,20 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import java.time.Instant; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; /** 在 IAM 状态事务内递增安全版本并写入可靠事件发送箱。 */ @Service public class SecurityEventOutboxService { + private static final List OVERSEAS_AUTHORITY_PREFIXES = List.of( + "platform:overseas-warehouse:", "platform:overseas-business:", + "platform:overseas-master-data:", "merchant:warehouse:", + "merchant:cross-border:", "platform:finance:", "lastleg:"); @Resource private JdbcTemplate jdbc; @Resource private ObjectMapper objectMapper; + @Resource private AuthorizationQueryMapper authorizationQueries; public void userStatus(long authUserId, long tenantId, String appCode, String status) { long version = bump(authUserId); @@ -39,8 +48,13 @@ public class SecurityEventOutboxService { public void permissionChanged(long authUserId, long tenantId, String appCode, String reason) { long version = bump(authUserId); + Set roles = Set.copyOf(authorizationQueries.findRolesByIdentity(authUserId, tenantId, appCode)); + Set authorities = authorizationQueries.findPermissionsByIdentity(authUserId, tenantId, appCode) + .stream().filter(permission -> permission != null && OVERSEAS_AUTHORITY_PREFIXES.stream() + .anyMatch(permission::startsWith)).collect(Collectors.toUnmodifiableSet()); write(EventTypes.IAM_PERMISSION_VERSION_CHANGED, "AUTHORIZATION", String.valueOf(authUserId), tenantId, version, - new IamSecurityEventsV1.PermissionVersionChanged(authUserId, tenantId, appCode, version, reason, Instant.now())); + new IamSecurityEventsV1.PermissionVersionChanged(authUserId, tenantId, appCode, version, reason, + Instant.now(), roles, authorities)); } private long bump(long authUserId) { diff --git a/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxRelayTest.java b/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxRelayTest.java index aa212d14b..9ceef4efc 100644 --- a/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxRelayTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxRelayTest.java @@ -80,7 +80,9 @@ class SecurityEventOutboxRelayTest { private EventEnvelope envelope(RegionCode target) { 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-000000000081", EventTypes.IAM_PERMISSION_VERSION_CHANGED, IamSecurityEventsV1.VERSION, diff --git a/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java index fb1ce9e8b..b66d458b7 100644 --- a/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java @@ -2,6 +2,7 @@ package com.baiyue.iam.service; import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.event.IamSecurityEventsV1; +import com.baiyue.iam.mapper.AuthorizationQueryMapper; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -14,6 +15,8 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.util.ReflectionTestUtils; +import java.util.List; + import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; @@ -24,6 +27,7 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class SecurityEventOutboxServiceTest { @Mock JdbcTemplate jdbc; + @Mock AuthorizationQueryMapper authorizationQueries; private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); private SecurityEventOutboxService service; @@ -32,6 +36,7 @@ class SecurityEventOutboxServiceTest { service = new SecurityEventOutboxService(); ReflectionTestUtils.setField(service, "jdbc", jdbc); ReflectionTestUtils.setField(service, "objectMapper", objectMapper); + ReflectionTestUtils.setField(service, "authorizationQueries", authorizationQueries); } @Test @@ -71,4 +76,27 @@ class SecurityEventOutboxServiceTest { assertThat(payload.status()).isEqualTo(expectedSecurityStatus); assertThat(envelope.aggregateVersion()).isEqualTo(3L); } + + @Test + void permissionEventCarriesOnlyTheOverseasAuthorizationSnapshot() throws Exception { + when(jdbc.queryForObject(anyString(), eq(Long.class), any())).thenReturn(7L); + when(authorizationQueries.findRolesByIdentity(101L, 0L, "ADMIN")) + .thenReturn(List.of("PLATFORM_ADMIN")); + when(authorizationQueries.findPermissionsByIdentity(101L, 0L, "ADMIN")) + .thenReturn(List.of("platform:overseas-warehouse:orders:view", "platform:iam:users:view", + "lastleg:orders:view")); + + service.permissionChanged(101L, 0L, "ADMIN", "ROLE_CHANGED"); + + ArgumentCaptor parameters = ArgumentCaptor.forClass(Object[].class); + verify(jdbc).update(eq("INSERT INTO iam_security_event_outbox(event_id,event_type,aggregate_type,aggregate_id,payload_json) " + + "VALUES (?,?,?,?,CAST(? AS JSON))"), parameters.capture()); + EventEnvelope envelope = objectMapper.readValue(String.valueOf(parameters.getValue()[4]), EventEnvelope.class); + IamSecurityEventsV1.PermissionVersionChanged payload = objectMapper.treeToValue( + envelope.payload(), IamSecurityEventsV1.PermissionVersionChanged.class); + assertThat(payload.roles()).containsExactly("PLATFORM_ADMIN"); + assertThat(payload.authorities()).containsExactlyInAnyOrder( + "platform:overseas-warehouse:orders:view", "lastleg:orders:view"); + assertThat(payload.authorities()).doesNotContain("platform:iam:users:view"); + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java index cb301fd7f..b7a7e72ae 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java @@ -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 roles, Set authorities) { + public ValidationResponse { + roles = roles == null ? Set.of() : Set.copyOf(roles); + authorities = authorities == null ? Set.of() : Set.copyOf(authorities); + } + public static ValidationResponse accepted(Set roles, Set 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()); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java index dcfe0ab4b..298d506c6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java @@ -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 roles, Set 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 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 roles, + Set authorities) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java index b23605379..1d8224b3f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java @@ -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 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 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 readSet(String json) throws Exception { + if (json == null || json.isBlank()) return Set.of(); + return objectMapper.readValue(json, new TypeReference>() { }); + } } diff --git a/lastleg-service/src/main/resources/application.yml b/lastleg-service/src/main/resources/application.yml index 023b0346d..3213abf67 100644 --- a/lastleg-service/src/main/resources/application.yml +++ b/lastleg-service/src/main/resources/application.yml @@ -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} diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index ea06ba3de..d1c23ea82 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -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'); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/security/LastlegSecurityContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/security/LastlegSecurityContractTest.java index a004dd8d4..76bf71249 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/security/LastlegSecurityContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/security/LastlegSecurityContractTest.java @@ -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") diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionTransportTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionTransportTest.java index 39d78d387..42f93f8df 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionTransportTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionTransportTest.java @@ -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, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationServiceTest.java index 5b934e0c6..3bf58ccf4 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationServiceTest.java @@ -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); diff --git a/warehouse-overseas-service/src/main/resources/application.yml b/warehouse-overseas-service/src/main/resources/application.yml index d7387e408..bbc38eb5f 100644 --- a/warehouse-overseas-service/src/main/resources/application.yml +++ b/warehouse-overseas-service/src/main/resources/application.yml @@ -46,7 +46,7 @@ baiyue: token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} security: 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}