暂存提交

This commit is contained in:
2026-08-19 16:24:02 +08:00
parent f5c4bf130a
commit 6acb6178da
354 changed files with 25000 additions and 198 deletions
+30
View File
@@ -0,0 +1,30 @@
# Gateway 模块指引
## 职责边界
- 负责海外 API 统一入口、路由、RS256/JWKS 验签、Audience 和必要 Claims 校验。
- 不保存签名私钥,不依赖国内 Redis 会话,不在网关实现尾程业务逻辑。
## 快速索引
| 功能 | 入口 |
|---|---|
| 安全链配置 | `src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java` |
| Audience 校验 | `config/AudienceJwtValidator.java` |
| 必要 Claims 校验 | `config/RequiredClaimsJwtValidator.java` |
| 安全配置项 | `config/GatewaySecurityProperties.java` |
| 可信身份头传递 | `filter/TrustedIdentityHeaderFilter.java` |
| 环境配置 | `src/main/resources/application.yml`、`../config/nacos/` |
| 测试 | `src/test/java/com/baiyue/gateway/config/` |
## 安全规则
- 生产令牌 `aud` 必须包含 `lastleg-api`,并包含配置要求的身份声明。
- 网关必须清除外部请求伪造的可信身份头,仅传递验签后生成的身份信息。
- JWKS 地址、Issuer 和 Audience 必须外部配置;不得在源码写死生产密钥。
## 修改与验证
- 定向验证:`mvn -pl gateway -am test`。
- 安全变更覆盖合法令牌、错误 Audience、缺失 Claims、过期/错误签名和身份头伪造测试。
+69
View File
@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.baiyue.firstleg</groupId>
<artifactId>baiyue-firstleg-platform</artifactId>
<version>1.0.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>gateway-overseas</artifactId>
<name>gateway-overseas</name>
<dependencies>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
</dependency>
<dependency>
<groupId>com.baiyue.shared</groupId>
<artifactId>baiyue-common-core</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>
@@ -0,0 +1,14 @@
package com.baiyue.gateway;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* API 网关启动入口,承载统一路由、认证校验和可信身份头转发。
*/
@SpringBootApplication
public class BaiyueGatewayOverseasApplication {
public static void main(String[] args) {
SpringApplication.run(BaiyueGatewayOverseasApplication.class, args);
}
}
@@ -0,0 +1,32 @@
package com.baiyue.gateway.config;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.ReactiveAuthorizationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.web.server.authorization.AuthorizationContext;
import reactor.core.publisher.Mono;
/**
* 网关应用范围授权器,根据 JWT 的 {@code app_code} 声明隔离商户端与平台端接口。
*/
final class AppScopeAuthorizationManager implements ReactiveAuthorizationManager<AuthorizationContext> {
private final String expectedAppCode;
AppScopeAuthorizationManager(String expectedAppCode) {
this.expectedAppCode = expectedAppCode;
}
@Override
public Mono<AuthorizationDecision> check(Mono<Authentication> authentication,
AuthorizationContext context) {
return authentication.filter(Authentication::isAuthenticated)
.map(this::hasExpectedScope).map(AuthorizationDecision::new)
.defaultIfEmpty(new AuthorizationDecision(false));
}
private boolean hasExpectedScope(Authentication authentication) {
return authentication instanceof JwtAuthenticationToken jwt
&& expectedAppCode.equals(jwt.getToken().getClaimAsString("app_code"));
}
}
@@ -0,0 +1,23 @@
package com.baiyue.gateway.config;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;
final class AudienceJwtValidator implements OAuth2TokenValidator<Jwt> {
private final String requiredAudience;
AudienceJwtValidator(String requiredAudience) {
this.requiredAudience = requiredAudience;
}
@Override
public OAuth2TokenValidatorResult validate(Jwt jwt) {
if (jwt.getAudience().contains(requiredAudience)) {
return OAuth2TokenValidatorResult.success();
}
return OAuth2TokenValidatorResult.failure(new OAuth2Error(
"invalid_token", "JWT audience 不允许访问海外 API", null));
}
}
@@ -0,0 +1,84 @@
package com.baiyue.gateway.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder;
import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder;
import org.springframework.security.web.server.SecurityWebFilterChain;
import reactor.core.publisher.Mono;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 海外网关响应式安全配置,使用国内认证中心公开的 JWKS 验证 JWT。
*/
@Configuration
@EnableConfigurationProperties(GatewaySecurityProperties.class)
public class GatewaySecurityConfig {
@Bean
SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http,
GatewaySecurityProperties properties,
ObjectMapper objectMapper) {
var exchanges = http.authorizeExchange();
properties.permitPaths().forEach(path -> exchanges.pathMatchers(path).permitAll());
exchanges.pathMatchers("/api/merchant/**")
.access(new AppScopeAuthorizationManager("MERCHANT"));
exchanges.pathMatchers("/api/platform/**")
.access(new AppScopeAuthorizationManager("PLATFORM"));
exchanges.anyExchange().authenticated();
return http.csrf(ServerHttpSecurity.CsrfSpec::disable)
.httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
.formLogin(ServerHttpSecurity.FormLoginSpec::disable)
.oauth2ResourceServer(oauth -> oauth.jwt(jwt -> { })
.authenticationEntryPoint((exchange, exception) -> writeError(exchange, objectMapper,
HttpStatus.UNAUTHORIZED, "A0401", "未登录或令牌已失效")))
.exceptionHandling(errors -> errors.accessDeniedHandler((exchange, exception) ->
writeError(exchange, objectMapper, HttpStatus.FORBIDDEN, "A0403", "无权访问该资源")))
.build();
}
@Bean
ReactiveJwtDecoder reactiveJwtDecoder(GatewaySecurityProperties properties) {
NimbusReactiveJwtDecoder decoder = NimbusReactiveJwtDecoder
.withJwkSetUri(properties.jwkSetUri())
.jwsAlgorithms(algorithms -> {
algorithms.add(SignatureAlgorithm.RS256);
algorithms.add(SignatureAlgorithm.ES256);
})
.build();
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer(properties.issuer()),
new AudienceJwtValidator(properties.audience()),
new RequiredClaimsJwtValidator()));
return decoder;
}
static Mono<Void> writeError(org.springframework.web.server.ServerWebExchange exchange,
ObjectMapper mapper, HttpStatus status, String code, String message) {
exchange.getResponse().setStatusCode(status);
exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
Map<String, Object> body = new LinkedHashMap<>();
body.put("code", code);
body.put("message", message);
body.put("data", null);
body.put("requestId", exchange.getRequest().getId());
body.put("timestamp", Instant.now().toString());
try {
byte[] bytes = mapper.writeValueAsBytes(body);
return exchange.getResponse().writeWith(
Mono.just(exchange.getResponse().bufferFactory().wrap(bytes)));
} catch (Exception exception) {
return exchange.getResponse().setComplete();
}
}
}
@@ -0,0 +1,27 @@
package com.baiyue.gateway.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.List;
/**
* 海外网关安全配置,只保存验签所需的签发方和公钥集地址。
*/
@ConfigurationProperties(prefix = "baiyue.security")
public record GatewaySecurityProperties(String jwkSetUri,
String issuer,
String audience,
List<String> permitPaths) {
public GatewaySecurityProperties {
if (jwkSetUri == null || jwkSetUri.isBlank()) {
throw new IllegalArgumentException("必须配置 JWT_JWK_SET_URI");
}
if (issuer == null || issuer.isBlank()) {
throw new IllegalArgumentException("必须配置 JWT_ISSUER");
}
if (audience == null || audience.isBlank()) {
throw new IllegalArgumentException("必须配置 JWT_AUDIENCE");
}
permitPaths = permitPaths == null ? List.of() : List.copyOf(permitPaths);
}
}
@@ -0,0 +1,26 @@
package com.baiyue.gateway.config;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;
import java.util.List;
/** Validates the identity and authorization claims required by overseas APIs. */
final class RequiredClaimsJwtValidator implements OAuth2TokenValidator<Jwt> {
private static final List<String> REQUIRED_CLAIMS = List.of(
"sub", "tenant_id", "app_code", "permission_version");
@Override
public OAuth2TokenValidatorResult validate(Jwt jwt) {
for (String claim : REQUIRED_CLAIMS) {
Object value = jwt.getClaims().get(claim);
if (value == null || value.toString().isBlank()) {
return OAuth2TokenValidatorResult.failure(new OAuth2Error(
"invalid_token", "JWT 缺少必要声明: " + claim, null));
}
}
return OAuth2TokenValidatorResult.success();
}
}
@@ -0,0 +1,73 @@
package com.baiyue.gateway.filter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.util.List;
/**
* 网关可信身份头过滤器,清除外部伪造身份头,并从已认证 JWT 注入用户、租户和应用范围信息。
*/
@Component
public class TrustedIdentityHeaderFilter implements GlobalFilter, Ordered {
private static final List<String> TRUSTED_HEADERS = List.of(
"X-User-Id", "X-Membership-Id", "X-Tenant-Id", "X-Operator-Tenant-Id",
"X-App-Code", "X-Client-Type"
);
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
return exchange.getPrincipal()
.cast(Authentication.class)
.defaultIfEmpty(new AnonymousAuthentication())
.flatMap(authentication -> {
var requestBuilder = exchange.getRequest().mutate();
requestBuilder.headers(headers -> TRUSTED_HEADERS.forEach(headers::remove));
if (authentication instanceof JwtAuthenticationToken jwt && authentication.isAuthenticated()) {
put(requestBuilder, "X-User-Id", jwt.getToken().getSubject());
put(requestBuilder, "X-Membership-Id", claim(jwt, "membership_id"));
put(requestBuilder, "X-Tenant-Id", claim(jwt, "tenant_id"));
put(requestBuilder, "X-Operator-Tenant-Id", claim(jwt, "operator_tenant_id"));
put(requestBuilder, "X-App-Code", claim(jwt, "app_code"));
put(requestBuilder, "X-Client-Type", claim(jwt, "client_type"));
}
return chain.filter(exchange.mutate().request(requestBuilder.build()).build());
});
}
private static String claim(JwtAuthenticationToken jwt, String name) {
Object value = jwt.getToken().getClaims().get(name);
return value == null ? null : value.toString();
}
private static void put(org.springframework.http.server.reactive.ServerHttpRequest.Builder builder,
String name,
String value) {
if (value != null && !value.isBlank()) {
builder.header(name, value);
}
}
@Override
public int getOrder() {
return -100;
}
private static final class AnonymousAuthentication implements Authentication {
@Override public java.util.Collection<? extends org.springframework.security.core.GrantedAuthority> getAuthorities() { return List.of(); }
@Override public Object getCredentials() { return ""; }
@Override public Object getDetails() { return null; }
@Override public Object getPrincipal() { return "anonymous"; }
@Override public boolean isAuthenticated() { return false; }
@Override public void setAuthenticated(boolean isAuthenticated) { }
@Override public String getName() { return "anonymous"; }
}
}
@@ -0,0 +1,69 @@
spring:
config:
activate:
on-profile: overseas
cloud:
gateway:
discovery:
locator:
enabled: false
routes:
- id: lastleg-service
uri: lb://lastleg-service
predicates: ["Path=/api/merchant/v1/last-leg/**,/api/platform/v1/last-leg/**"]
- id: warehouse-overseas-service
uri: lb://warehouse-overseas-service
predicates: ["Path=/api/merchant/v1/warehouses/**,/api/platform/v1/warehouses/**,/api/platform/v1/warehouse-admin/**"]
- id: file-service
uri: lb://file-service
predicates: ["Path=/api/merchant/v1/files/**,/api/platform/v1/files/**"]
- id: print-service
uri: lb://print-service
predicates: ["Path=/api/merchant/v1/print/**,/api/platform/v1/print/**"]
- id: lastleg-openapi
uri: lb://lastleg-service
predicates: [Path=/lastleg-service/v3/api-docs/**]
filters: [StripPrefix=1]
- id: warehouse-openapi
uri: lb://warehouse-overseas-service
predicates: [Path=/warehouse-overseas-service/v3/api-docs/**]
filters: [StripPrefix=1]
- id: file-openapi
uri: lb://file-service
predicates: [Path=/file-service/v3/api-docs/**]
filters: [StripPrefix=1]
- id: print-openapi
uri: lb://print-service
predicates: [Path=/print-service/v3/api-docs/**]
filters: [StripPrefix=1]
- id: audit-openapi
uri: lb://audit-service
predicates: [Path=/audit-service/v3/api-docs/**]
filters: [StripPrefix=1]
- id: integration-openapi
uri: lb://integration-service
predicates: [Path=/integration-service/v3/api-docs/**]
filters: [StripPrefix=1]
- id: event-bridge-openapi
uri: lb://event-bridge-service
predicates: [Path=/event-bridge-service/v3/api-docs/**]
filters: [StripPrefix=1]
baiyue:
deployment:
region-code: ${REGION_CODE:OVERSEAS}
cell: ${DEPLOYMENT_CELL:prod-overseas}
data-residency: ${DATA_RESIDENCY:OVERSEAS}
springdoc:
swagger-ui:
urls:
- { name: lastleg-merchant, url: /lastleg-service/v3/api-docs/merchant }
- { name: warehouse-merchant, url: /warehouse-overseas-service/v3/api-docs/merchant }
- { name: file-platform, url: /file-service/v3/api-docs/platform }
- { name: file-merchant, url: /file-service/v3/api-docs/merchant }
- { name: print-platform, url: /print-service/v3/api-docs/platform }
- { name: print-merchant, url: /print-service/v3/api-docs/merchant }
- { name: audit, url: /audit-service/v3/api-docs }
- { name: integration, url: /integration-service/v3/api-docs }
- { name: event-bridge, url: /event-bridge-service/v3/api-docs }
@@ -0,0 +1,67 @@
server:
port: ${GATEWAY_PORT:9300}
spring:
data:
redis:
host: ${REDIS_HOST:127.0.0.1}
port: ${REDIS_PORT:6380}
application:
name: gateway-overseas
profiles:
default: overseas
autoconfigure:
exclude:
- org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
config:
import:
- optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true
- optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true
cloud:
nacos:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858}
username: ${NACOS_USERNAME:nacos}
password: ${NACOS_PASSWORD:nacos}
discovery:
enabled: ${NACOS_DISCOVERY_ENABLED:true}
register-enabled: ${NACOS_REGISTER_ENABLED:true}
group: ${NACOS_GROUP:BAIYUE}
namespace: ${NACOS_NAMESPACE:public}
config:
namespace: ${NACOS_NAMESPACE:public}
gateway:
discovery:
locator:
enabled: false
baiyue:
security:
issuer: ${JWT_ISSUER:https://auth.example.com}
audience: ${JWT_AUDIENCE:lastleg-api}
jwk-set-uri: ${JWT_JWK_SET_URI:https://auth.example.com/.well-known/jwks.json}
permit-paths:
- /actuator/health
- /swagger-ui/**
- /webjars/**
- /v3/api-docs/**
- /lastleg-service/v3/api-docs/**
- /warehouse-overseas-service/v3/api-docs/**
- /file-service/v3/api-docs/**
- /print-service/v3/api-docs/**
- /audit-service/v3/api-docs/**
- /integration-service/v3/api-docs/**
- /event-bridge-service/v3/api-docs/**
management:
endpoints:
web:
exposure:
include: health,info,prometheus
endpoint:
health:
probes:
enabled: true
springdoc:
api-docs.enabled: ${SWAGGER_ENABLED:false}
swagger-ui.enabled: ${SWAGGER_ENABLED:false}
@@ -0,0 +1,55 @@
package com.baiyue.gateway.config;
import org.junit.jupiter.api.Test;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import java.time.Instant;
class AppScopeAuthorizationManagerTest {
@Test
void acceptsMatchingApplicationScope() {
var manager = new AppScopeAuthorizationManager("MERCHANT");
StepVerifier.create(manager.check(Mono.just(authentication("MERCHANT")), null))
.expectNextMatches(decision -> decision.isGranted())
.verifyComplete();
}
@Test
void rejectsDifferentApplicationScope() {
var manager = new AppScopeAuthorizationManager("PLATFORM");
StepVerifier.create(manager.check(Mono.just(authentication("MERCHANT")), null))
.expectNextMatches(decision -> !decision.isGranted())
.verifyComplete();
}
@Test
void rejectsMissingApplicationScope() {
var manager = new AppScopeAuthorizationManager("PLATFORM");
Jwt jwt = Jwt.withTokenValue("token")
.header("alg", "none")
.subject("1")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(60))
.build();
StepVerifier.create(manager.check(Mono.just(new JwtAuthenticationToken(
jwt, java.util.List.of(new SimpleGrantedAuthority("authenticated")))), null))
.expectNextMatches(decision -> !decision.isGranted())
.verifyComplete();
}
private JwtAuthenticationToken authentication(String appCode) {
Jwt jwt = Jwt.withTokenValue("token")
.header("alg", "none")
.subject("1")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(60))
.claim("app_code", appCode)
.build();
return new JwtAuthenticationToken(jwt,
java.util.List.of(new SimpleGrantedAuthority("authenticated")));
}
}
@@ -0,0 +1,33 @@
package com.baiyue.gateway.config;
import org.junit.jupiter.api.Test;
import org.springframework.security.oauth2.jwt.Jwt;
import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class AudienceJwtValidatorTest {
private final AudienceJwtValidator validator = new AudienceJwtValidator("lastleg-api");
@Test
void acceptsTokenForLastlegApi() {
assertThat(validator.validate(jwt(List.of("cn-api", "lastleg-api"))).hasErrors()).isFalse();
}
@Test
void rejectsTokenForAnotherApi() {
assertThat(validator.validate(jwt(List.of("cn-api"))).hasErrors()).isTrue();
}
private Jwt jwt(List<String> audiences) {
return Jwt.withTokenValue("token")
.header("alg", "RS256")
.subject("1001")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(300))
.audience(audiences)
.build();
}
}
@@ -0,0 +1,36 @@
package com.baiyue.gateway.config;
import org.junit.jupiter.api.Test;
import org.springframework.security.oauth2.jwt.Jwt;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
class RequiredClaimsJwtValidatorTest {
private final RequiredClaimsJwtValidator validator = new RequiredClaimsJwtValidator();
@Test
void acceptsCompleteIdentityClaims() {
assertThat(validator.validate(jwt(true)).hasErrors()).isFalse();
}
@Test
void rejectsMissingPermissionVersion() {
assertThat(validator.validate(jwt(false)).hasErrors()).isTrue();
}
private Jwt jwt(boolean includePermissionVersion) {
Jwt.Builder builder = Jwt.withTokenValue("token")
.header("alg", "RS256")
.subject("1001")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(300))
.claim("tenant_id", "2001")
.claim("app_code", "PLATFORM");
if (includePermissionVersion) {
builder.claim("permission_version", 3L);
}
return builder.build();
}
}