diff --git a/it/xds-client/build.gradle b/it/xds-client/build.gradle index ffb9559ad78..16490854965 100644 --- a/it/xds-client/build.gradle +++ b/it/xds-client/build.gradle @@ -9,5 +9,23 @@ dependencies { exclude group: 'io.envoyproxy.controlplane', module: 'api' } + testImplementation project(':athenz') testImplementation project(':thrift0.18') + testImplementation project(':xds-athenz') + testImplementation libs.athenz.zms.client + testImplementation libs.testcontainers.junit.jupiter } + +// Copy the Athenz Docker resources from ':athenz'. +task copyTestResources(type: Copy) { + from("${rootProject.projectDir}/athenz/src/test") { + include 'resources/**' + include '**/AthenzDocker.java' + include '**/AthenzExtension.java' + } + into "${project.ext.genSrcDir}/test" +} + +tasks.compileTestJava.dependsOn(tasks.copyTestResources) +tasks.processTestResources.dependsOn(tasks.copyTestResources) +tasks.sourcesJar.dependsOn(tasks.copyTestResources) diff --git a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/athenz/AthenzAccessTokenConstraintFilterTest.java b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/athenz/AthenzAccessTokenConstraintFilterTest.java new file mode 100644 index 00000000000..954dd92909f --- /dev/null +++ b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/athenz/AthenzAccessTokenConstraintFilterTest.java @@ -0,0 +1,384 @@ +/* + * Copyright 2026 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.xds.it.athenz; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.io.File; +import java.net.URI; +import java.nio.file.Path; + +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.testcontainers.junit.jupiter.EnabledIfDockerAvailable; + +import com.google.common.collect.ImmutableList; +import com.yahoo.athenz.zms.Assertion; +import com.yahoo.athenz.zms.AssertionEffect; +import com.yahoo.athenz.zms.Policy; +import com.yahoo.athenz.zms.ZMSClient; + +import com.linecorp.armeria.client.BlockingWebClient; +import com.linecorp.armeria.client.ClientTlsSpec; +import com.linecorp.armeria.client.RequestOptions; +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.client.athenz.AthenzTokenClient; +import com.linecorp.armeria.common.AggregatedHttpResponse; +import com.linecorp.armeria.common.HttpMethod; +import com.linecorp.armeria.common.HttpRequest; +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.SessionProtocol; +import com.linecorp.armeria.server.ServerBuilder; +import com.linecorp.armeria.server.ServerPort; +import com.linecorp.armeria.server.athenz.AthenzDocker; +import com.linecorp.armeria.server.athenz.AthenzExtension; +import com.linecorp.armeria.testing.junit5.server.SelfSignedCertificateExtension; +import com.linecorp.armeria.testing.junit5.server.ServerExtension; +import com.linecorp.armeria.xds.XdsBootstrap; +import com.linecorp.armeria.xds.it.XdsCertificateExtension; +import com.linecorp.armeria.xds.it.XdsResourceReader; +import com.linecorp.armeria.xds.server.XdsServerPlugin; + +import io.envoyproxy.envoy.config.bootstrap.v3.Bootstrap; + +@EnabledIfDockerAvailable +class AthenzAccessTokenConstraintFilterTest { + + private static final String LISTENER_NAME = "server-listener"; + private static final String POLICY_NAME = "constraint-policy"; + private static final String ATHENZ_RESOURCES = "gen-src/test/resources"; + + private static final ServerPort xdsPort = + new ServerPort(0, SessionProtocol.HTTP, SessionProtocol.HTTPS); + + @RegisterExtension + @Order(0) + static final AthenzExtension athenz = + new AthenzExtension(new File("gen-src/test/resources/docker/docker-compose.yml")) { + @Override + protected void scaffold(ZMSClient zmsClient) { + // Literal mapping: action=obtain, resource=testing:literal + final Assertion literalAssertion = + newAssertion("obtain", AthenzDocker.TEST_DOMAIN_NAME + ":literal"); + // Default mapping: action=get (lower of GET), resource=testing:/default (the path) + final Assertion defaultAssertion = + newAssertion("get", AthenzDocker.TEST_DOMAIN_NAME + ":/default"); + // Template mapping: action=obtain, resource=testing:template + final Assertion templateAssertion = + newAssertion("obtain", AthenzDocker.TEST_DOMAIN_NAME + ":template"); + // No-prefix mapping: action=obtain, resource=testing:no_prefix + // The filter config uses just "no_prefix" (without the domain: prefix) + final Assertion noPrefixAssertion = + newAssertion("obtain", AthenzDocker.TEST_DOMAIN_NAME + ":no_prefix"); + + final Policy policy = new Policy(); + policy.setName(AthenzDocker.TEST_DOMAIN_NAME + ":policy." + POLICY_NAME); + policy.setAssertions(ImmutableList.of(literalAssertion, defaultAssertion, + templateAssertion, noPrefixAssertion)); + zmsClient.putPolicy(AthenzDocker.TEST_DOMAIN_NAME, POLICY_NAME, + "create-policy-audit-ref", policy); + } + + private static Assertion newAssertion(String action, String resource) { + final Assertion assertion = new Assertion(); + assertion.setRole(AthenzDocker.TEST_DOMAIN_NAME + ":role." + AthenzDocker.USER_ROLE); + assertion.setAction(action); + assertion.setResource(resource); + assertion.setEffect(AssertionEffect.ALLOW); + return assertion; + } + }; + + @RegisterExtension + @Order(1) + static final XdsCertificateExtension serverCert = + new XdsCertificateExtension(new SelfSignedCertificateExtension("127.0.0.1")); + + @RegisterExtension + @Order(2) + static final ServerExtension server = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) { + final Bootstrap bootstrap = + XdsResourceReader.fromYaml(bootstrapYaml(), Bootstrap.class); + final XdsBootstrap xdsBootstrap = XdsBootstrap.of(bootstrap); + sb.plugin(XdsServerPlugin.builder(xdsBootstrap, LISTENER_NAME) + .port(xdsPort).build()); + sb.service("/literal", (ctx, req) -> HttpResponse.of("ok")); + sb.service("/default", (ctx, req) -> HttpResponse.of("ok")); + sb.service("/template", (ctx, req) -> HttpResponse.of("ok")); + sb.service("/no-prefix", (ctx, req) -> HttpResponse.of("ok")); + } + }; + + private static String bootstrapYaml() { + final URI ztsUri = athenz.ztsUri(); + final Path certPath = serverCert.certificateFile().toPath(); + final Path keyPath = serverCert.privateKeyFile().toPath(); + final String serviceCertFile = ATHENZ_RESOURCES + AthenzDocker.ATHENZ_CERTS + + AthenzDocker.TEST_SERVICE + "/cert.pem"; + final String serviceKeyFile = ATHENZ_RESOURCES + AthenzDocker.ATHENZ_CERTS + + AthenzDocker.TEST_SERVICE + "/key.pem"; + final String caCertFile = ATHENZ_RESOURCES + AthenzDocker.CA_CERT_FILE; + + final String domain = AthenzDocker.TEST_DOMAIN_NAME; + + //language=YAML + return """ + static_resources: + listeners: + - name: %s + default_filter_chain: + filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters\ + .network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: ingress_http + route_config: + name: local_route + virtual_hosts: + - name: local_service + domains: ["*"] + routes: + - match: + prefix: "/literal" + non_forwarding_action: {} + typed_per_filter_config: + athenz.access_token_constraint: + "@type": type.googleapis.com/armeria.xds\ + .athenz.AccessTokenConstraintConfig + zts_cluster_name: zts-cluster + access_token_constraint: + constraint_domain: %s + syntax_version: 1 + assertion_mapping: + rules: + - action: + literal: obtain + resource: + literal: "%s:literal" + - match: + prefix: "/template" + non_forwarding_action: {} + typed_per_filter_config: + athenz.access_token_constraint: + "@type": type.googleapis.com/armeria.xds\ + .athenz.AccessTokenConstraintConfig + zts_cluster_name: zts-cluster + access_token_constraint: + constraint_domain: %s + syntax_version: 1 + assertion_mapping: + rules: + - conditions: + - attribute: + well_known: WELL_KNOWN_ENDPOINT_ATTRIBUTE_METHOD + matcher: + exact: GET + - attribute: + well_known: WELL_KNOWN_ENDPOINT_ATTRIBUTE_PATH + name: pathCapture + matcher: + safe_regex: + regex: "/(.+)" + action: + literal: obtain + resource: + template: + template: "%s:${match.pathCapture.1}" + - match: + prefix: "/no-prefix" + non_forwarding_action: {} + typed_per_filter_config: + athenz.access_token_constraint: + "@type": type.googleapis.com/armeria.xds\ + .athenz.AccessTokenConstraintConfig + zts_cluster_name: zts-cluster + access_token_constraint: + constraint_domain: %s + syntax_version: 1 + assertion_mapping: + rules: + - action: + literal: obtain + resource: + literal: no_prefix + - match: + prefix: "/" + non_forwarding_action: {} + http_filters: + - name: athenz.access_token_constraint + typed_config: + "@type": type.googleapis.com/armeria.xds\ + .athenz.AccessTokenConstraintConfig + zts_cluster_name: zts-cluster + access_token_constraint: + constraint_domain: %s + syntax_version: 1 + - name: envoy.filters.http.router + transport_socket: + name: envoy.transport_sockets.downstream_tls + typed_config: + "@type": type.googleapis.com/envoy.extensions.transport_sockets\ + .tls.v3.DownstreamTlsContext + common_tls_context: + tls_certificates: + - certificate_chain: + filename: '%s' + private_key: + filename: '%s' + clusters: + - name: zts-cluster + type: STATIC + load_assignment: + cluster_name: zts-cluster + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: %s + port_value: %d + transport_socket: + name: envoy.transport_sockets.tls + typed_config: + "@type": type.googleapis.com/envoy.extensions\ + .transport_sockets.tls.v3.UpstreamTlsContext + common_tls_context: + tls_certificates: + - certificate_chain: + filename: '%s' + private_key: + filename: '%s' + validation_context: + trusted_ca: + filename: '%s' + """.formatted( + LISTENER_NAME, + domain, domain, // literal per-route config + domain, domain, // template per-route config + domain, // no-prefix per-route config + domain, // filter-level config + certPath, keyPath, // DownstreamTlsContext certs + ztsUri.getHost(), ztsUri.getPort(), + serviceCertFile, serviceKeyFile, caCertFile); + } + + @Test + void noTokenReturnsUnauthorized() { + final BlockingWebClient client = xdsClient(); + final RequestOptions tlsOptions = tlsOptions(); + await().untilAsserted(() -> { + final AggregatedHttpResponse response = client.execute( + HttpRequest.of(HttpMethod.GET, "/literal"), tlsOptions); + assertThat(response.status()).isEqualTo(HttpStatus.UNAUTHORIZED); + }); + } + + @Test + void validTokenWithLiteralMapping() { + final String token = obtainAccessToken(); + final BlockingWebClient client = xdsClient(); + final RequestOptions tlsOptions = tlsOptions(); + await().untilAsserted(() -> { + final AggregatedHttpResponse response = client.execute( + HttpRequest.builder() + .get("/literal") + .header("authorization", "Bearer " + token) + .build(), + tlsOptions); + assertThat(response.status()).isEqualTo(HttpStatus.OK); + assertThat(response.contentUtf8()).isEqualTo("ok"); + }); + } + + @Test + void defaultMappingUsesMethodAndPath() { + final String token = obtainAccessToken(); + final BlockingWebClient client = xdsClient(); + final RequestOptions tlsOptions = tlsOptions(); + await().untilAsserted(() -> { + final AggregatedHttpResponse response = client.execute( + HttpRequest.builder() + .get("/default") + .header("authorization", "Bearer " + token) + .build(), + tlsOptions); + assertThat(response.status()).isEqualTo(HttpStatus.OK); + assertThat(response.contentUtf8()).isEqualTo("ok"); + }); + } + + @Test + void literalMappingWithoutDomainPrefix() { + final String token = obtainAccessToken(); + final BlockingWebClient client = xdsClient(); + final RequestOptions tlsOptions = tlsOptions(); + await().untilAsserted(() -> { + final AggregatedHttpResponse response = client.execute( + HttpRequest.builder() + .get("/no-prefix") + .header("authorization", "Bearer " + token) + .build(), + tlsOptions); + assertThat(response.status()).isEqualTo(HttpStatus.OK); + assertThat(response.contentUtf8()).isEqualTo("ok"); + }); + } + + @Test + void validTokenWithTemplateMapping() { + final String token = obtainAccessToken(); + final BlockingWebClient client = xdsClient(); + final RequestOptions tlsOptions = tlsOptions(); + await().untilAsserted(() -> { + final AggregatedHttpResponse response = client.execute( + HttpRequest.builder() + .get("/template") + .header("authorization", "Bearer " + token) + .build(), + tlsOptions); + assertThat(response.status()).isEqualTo(HttpStatus.OK); + assertThat(response.contentUtf8()).isEqualTo("ok"); + }); + } + + private static RequestOptions tlsOptions() { + final ClientTlsSpec clientTlsSpec = + ClientTlsSpec.builder() + .trustedCertificates(serverCert.certificate()) + .build(); + return RequestOptions.builder().clientTlsSpec(clientTlsSpec).build(); + } + + private static BlockingWebClient xdsClient() { + return WebClient.of("https://127.0.0.1:" + xdsPort.actualPort()).blocking(); + } + + private static String obtainAccessToken() { + final AthenzTokenClient tokenClient = + AthenzTokenClient.builder(athenz.newZtsBaseClient(AthenzDocker.TEST_SERVICE)) + .domainName(AthenzDocker.TEST_DOMAIN_NAME) + .roleNames(ImmutableList.of(AthenzDocker.USER_ROLE)) + .build(); + return tokenClient.getToken().join(); + } +} diff --git a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/athenz/AthenzAccessTokenFilterTest.java b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/athenz/AthenzAccessTokenFilterTest.java new file mode 100644 index 00000000000..2ba69dda99e --- /dev/null +++ b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/athenz/AthenzAccessTokenFilterTest.java @@ -0,0 +1,285 @@ +/* + * Copyright 2026 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.xds.it.athenz; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.net.URI; + +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.testcontainers.junit.jupiter.EnabledIfDockerAvailable; + +import com.linecorp.armeria.client.BlockingWebClient; +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.common.AggregatedHttpResponse; +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.server.ServerBuilder; +import com.linecorp.armeria.server.athenz.AthenzDocker; +import com.linecorp.armeria.server.athenz.AthenzExtension; +import com.linecorp.armeria.testing.junit5.common.EventLoopExtension; +import com.linecorp.armeria.testing.junit5.server.ServerExtension; +import com.linecorp.armeria.xds.XdsBootstrap; +import com.linecorp.armeria.xds.client.endpoint.XdsHttpPreprocessor; +import com.linecorp.armeria.xds.it.XdsResourceReader; + +import io.envoyproxy.envoy.config.bootstrap.v3.Bootstrap; + +@EnabledIfDockerAvailable +class AthenzAccessTokenFilterTest { + + private static final String LISTENER_NAME = "listener1"; + private static final String ATHENZ_RESOURCES = "gen-src/test/resources"; + + @RegisterExtension + @Order(1) + static final AthenzExtension athenz = + new AthenzExtension(new File("gen-src/test/resources/docker/docker-compose.yml")); + + @RegisterExtension + @Order(2) + static final ServerExtension echoServer = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) { + sb.service("/echo-auth", (ctx, req) -> { + final String auth = req.headers().get("authorization"); + return HttpResponse.of(auth != null ? auth : "no-auth"); + }); + sb.http(0); + } + }; + + @RegisterExtension + static final EventLoopExtension eventLoop = new EventLoopExtension(); + + @Test + void tokenInjectedIntoAuthorizationHeader() { + final Bootstrap bootstrap = XdsResourceReader.fromYaml(bootstrapYaml(), Bootstrap.class); + try (XdsBootstrap xdsBootstrap = XdsBootstrap.of(bootstrap, eventLoop.get()); + XdsHttpPreprocessor preprocessor = + XdsHttpPreprocessor.ofListener(LISTENER_NAME, xdsBootstrap)) { + final BlockingWebClient client = WebClient.of(preprocessor).blocking(); + final AggregatedHttpResponse response = client.get("/echo-auth"); + assertThat(response.status()).isEqualTo(HttpStatus.OK); + assertThat(response.contentUtf8()).startsWith("Bearer "); + } + } + + @Test + void existingHeaderOverwrittenByFilter() { + final Bootstrap bootstrap = XdsResourceReader.fromYaml(bootstrapYaml(), Bootstrap.class); + try (XdsBootstrap xdsBootstrap = XdsBootstrap.of(bootstrap, eventLoop.get()); + XdsHttpPreprocessor preprocessor = + XdsHttpPreprocessor.ofListener(LISTENER_NAME, xdsBootstrap)) { + final BlockingWebClient client = WebClient.of(preprocessor).blocking(); + final AggregatedHttpResponse response = client.prepare() + .get("/echo-auth") + .header("authorization", "Bearer existingToken") + .execute(); + assertThat(response.status()).isEqualTo(HttpStatus.OK); + assertThat(response.contentUtf8()).startsWith("Bearer "); + } + } + + @Test + void tokenInjectedViaUpstreamFilter() { + final Bootstrap bootstrap = XdsResourceReader.fromYaml(upstreamBootstrapYaml(), Bootstrap.class); + try (XdsBootstrap xdsBootstrap = XdsBootstrap.of(bootstrap, eventLoop.get()); + XdsHttpPreprocessor preprocessor = + XdsHttpPreprocessor.ofListener(LISTENER_NAME, xdsBootstrap)) { + final BlockingWebClient client = WebClient.of(preprocessor).blocking(); + final AggregatedHttpResponse response = client.get("/echo-auth"); + assertThat(response.status()).isEqualTo(HttpStatus.OK); + assertThat(response.contentUtf8()).startsWith("Bearer "); + } + } + + private static String bootstrapYaml() { + final URI ztsUri = athenz.ztsUri(); + final String serviceCertFile = + ATHENZ_RESOURCES + AthenzDocker.ATHENZ_CERTS + AthenzDocker.TEST_SERVICE + "/cert.pem"; + final String serviceKeyFile = + ATHENZ_RESOURCES + AthenzDocker.ATHENZ_CERTS + AthenzDocker.TEST_SERVICE + "/key.pem"; + final String caCertFile = ATHENZ_RESOURCES + AthenzDocker.CA_CERT_FILE; + + //language=YAML + return """ + static_resources: + listeners: + - name: %s + api_listener: + api_listener: + "@type": type.googleapis.com/envoy.extensions.filters.network\ + .http_connection_manager.v3.HttpConnectionManager + stat_prefix: http + route_config: + name: local_route + virtual_hosts: + - name: local_service + domains: ["*"] + routes: + - match: + prefix: / + route: + cluster: echo-cluster + http_filters: + - name: athenz.access_token_target + typed_config: + "@type": type.googleapis.com/armeria.xds.athenz\ + .AccessTokenTargetConfig + zts_cluster_name: zts-cluster + access_token_target: + target_domain: %s + target_roles: ["%s"] + syntax_version: 1 + - name: envoy.filters.http.router + clusters: + - name: echo-cluster + type: STATIC + load_assignment: + cluster_name: echo-cluster + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: %s + port_value: %d + - name: zts-cluster + type: STATIC + load_assignment: + cluster_name: zts-cluster + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: %s + port_value: %d + transport_socket: + name: envoy.transport_sockets.tls + typed_config: + "@type": type.googleapis.com/envoy.extensions.transport_sockets\ + .tls.v3.UpstreamTlsContext + common_tls_context: + tls_certificates: + - certificate_chain: + filename: '%s' + private_key: + filename: '%s' + validation_context: + trusted_ca: + filename: '%s' + """.formatted( + LISTENER_NAME, + AthenzDocker.TEST_DOMAIN_NAME, AthenzDocker.USER_ROLE, + echoServer.httpSocketAddress().getHostString(), echoServer.httpPort(), + ztsUri.getHost(), ztsUri.getPort(), + serviceCertFile, serviceKeyFile, caCertFile); + } + + private static String upstreamBootstrapYaml() { + final URI ztsUri = athenz.ztsUri(); + final String serviceCertFile = + ATHENZ_RESOURCES + AthenzDocker.ATHENZ_CERTS + AthenzDocker.TEST_SERVICE + "/cert.pem"; + final String serviceKeyFile = + ATHENZ_RESOURCES + AthenzDocker.ATHENZ_CERTS + AthenzDocker.TEST_SERVICE + "/key.pem"; + final String caCertFile = ATHENZ_RESOURCES + AthenzDocker.CA_CERT_FILE; + + //language=YAML + return """ + static_resources: + listeners: + - name: %s + api_listener: + api_listener: + "@type": type.googleapis.com/envoy.extensions.filters.network\ + .http_connection_manager.v3.HttpConnectionManager + stat_prefix: http + route_config: + name: local_route + virtual_hosts: + - name: local_service + domains: ["*"] + routes: + - match: + prefix: / + route: + cluster: echo-cluster + http_filters: + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http\ + .router.v3.Router + upstream_http_filters: + - name: athenz.access_token_target + typed_config: + "@type": type.googleapis.com/armeria.xds.athenz\ + .AccessTokenTargetConfig + zts_cluster_name: zts-cluster + access_token_target: + target_domain: %s + target_roles: ["%s"] + syntax_version: 1 + clusters: + - name: echo-cluster + type: STATIC + load_assignment: + cluster_name: echo-cluster + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: %s + port_value: %d + - name: zts-cluster + type: STATIC + load_assignment: + cluster_name: zts-cluster + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: %s + port_value: %d + transport_socket: + name: envoy.transport_sockets.tls + typed_config: + "@type": type.googleapis.com/envoy.extensions.transport_sockets\ + .tls.v3.UpstreamTlsContext + common_tls_context: + tls_certificates: + - certificate_chain: + filename: '%s' + private_key: + filename: '%s' + validation_context: + trusted_ca: + filename: '%s' + """.formatted( + LISTENER_NAME, + AthenzDocker.TEST_DOMAIN_NAME, AthenzDocker.USER_ROLE, + echoServer.httpSocketAddress().getHostString(), echoServer.httpPort(), + ztsUri.getHost(), ztsUri.getPort(), + serviceCertFile, serviceKeyFile, caCertFile); + } +} diff --git a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/athenz/AthenzAccessTokenRpcFilterTest.java b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/athenz/AthenzAccessTokenRpcFilterTest.java new file mode 100644 index 00000000000..53ed3478227 --- /dev/null +++ b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/athenz/AthenzAccessTokenRpcFilterTest.java @@ -0,0 +1,168 @@ +/* + * Copyright 2026 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.xds.it.athenz; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.net.URI; + +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.testcontainers.junit.jupiter.EnabledIfDockerAvailable; + +import com.linecorp.armeria.client.thrift.ThriftClients; +import com.linecorp.armeria.server.ServerBuilder; +import com.linecorp.armeria.server.ServiceRequestContext; +import com.linecorp.armeria.server.athenz.AthenzDocker; +import com.linecorp.armeria.server.athenz.AthenzExtension; +import com.linecorp.armeria.server.thrift.THttpService; +import com.linecorp.armeria.testing.junit5.common.EventLoopExtension; +import com.linecorp.armeria.testing.junit5.server.ServerExtension; +import com.linecorp.armeria.xds.XdsBootstrap; +import com.linecorp.armeria.xds.client.endpoint.XdsRpcPreprocessor; +import com.linecorp.armeria.xds.it.XdsResourceReader; + +import io.envoyproxy.envoy.config.bootstrap.v3.Bootstrap; +import testing.xds.EchoService; + +@EnabledIfDockerAvailable +class AthenzAccessTokenRpcFilterTest { + + private static final String LISTENER_NAME = "listener1"; + private static final String ATHENZ_RESOURCES = "gen-src/test/resources"; + + @RegisterExtension + @Order(1) + static final AthenzExtension athenz = + new AthenzExtension(new File("gen-src/test/resources/docker/docker-compose.yml")); + + @RegisterExtension + @Order(2) + static final ServerExtension echoServer = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) { + sb.service("/", THttpService.of((EchoService.Iface) () -> { + final String auth = ServiceRequestContext.current().request() + .headers().get("authorization"); + return auth != null ? auth : "no-auth"; + })); + sb.http(0); + } + }; + + @RegisterExtension + static final EventLoopExtension eventLoop = new EventLoopExtension(); + + @Test + void tokenInjectedViaRpcPreprocessor() throws Exception { + final Bootstrap bootstrap = XdsResourceReader.fromYaml(bootstrapYaml(), Bootstrap.class); + try (XdsBootstrap xdsBootstrap = XdsBootstrap.of(bootstrap, eventLoop.get()); + XdsRpcPreprocessor preprocessor = + XdsRpcPreprocessor.ofListener(LISTENER_NAME, xdsBootstrap)) { + final EchoService.Iface client = + ThriftClients.newClient(preprocessor, EchoService.Iface.class); + final String result = client.echoAuth(); + assertThat(result).startsWith("Bearer "); + } + } + + private static String bootstrapYaml() { + final URI ztsUri = athenz.ztsUri(); + final String serviceCertFile = + ATHENZ_RESOURCES + AthenzDocker.ATHENZ_CERTS + AthenzDocker.TEST_SERVICE + "/cert.pem"; + final String serviceKeyFile = + ATHENZ_RESOURCES + AthenzDocker.ATHENZ_CERTS + AthenzDocker.TEST_SERVICE + "/key.pem"; + final String caCertFile = ATHENZ_RESOURCES + AthenzDocker.CA_CERT_FILE; + + //language=YAML + return """ + static_resources: + listeners: + - name: %s + api_listener: + api_listener: + "@type": type.googleapis.com/envoy.extensions.filters.network\ + .http_connection_manager.v3.HttpConnectionManager + stat_prefix: http + route_config: + name: local_route + virtual_hosts: + - name: local_service + domains: ["*"] + routes: + - match: + prefix: / + route: + cluster: echo-cluster + http_filters: + - name: athenz.access_token_target + typed_config: + "@type": type.googleapis.com/armeria.xds.athenz\ + .AccessTokenTargetConfig + zts_cluster_name: zts-cluster + access_token_target: + target_domain: %s + target_roles: ["%s"] + syntax_version: 1 + - name: envoy.filters.http.router + clusters: + - name: echo-cluster + type: STATIC + load_assignment: + cluster_name: echo-cluster + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: %s + port_value: %d + - name: zts-cluster + type: STATIC + load_assignment: + cluster_name: zts-cluster + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: %s + port_value: %d + transport_socket: + name: envoy.transport_sockets.tls + typed_config: + "@type": type.googleapis.com/envoy.extensions.transport_sockets\ + .tls.v3.UpstreamTlsContext + common_tls_context: + tls_certificates: + - certificate_chain: + filename: '%s' + private_key: + filename: '%s' + validation_context: + trusted_ca: + filename: '%s' + """.formatted( + LISTENER_NAME, + AthenzDocker.TEST_DOMAIN_NAME, AthenzDocker.USER_ROLE, + echoServer.httpSocketAddress().getHostString(), echoServer.httpPort(), + ztsUri.getHost(), ztsUri.getPort(), + serviceCertFile, serviceKeyFile, caCertFile); + } +} diff --git a/settings.gradle b/settings.gradle index bbc48ff9bd2..c7c2db93ca9 100644 --- a/settings.gradle +++ b/settings.gradle @@ -220,6 +220,7 @@ includeWithFlags ':tomcat8', 'java', 'publish', 'rel includeWithFlags ':tomcat9', 'java', 'publish', 'relocate', 'no_aggregation' includeWithFlags ':tomcat10', 'java11', 'publish', 'relocate' includeWithFlags ':xds', 'java', 'publish', 'relocate' +includeWithFlags ':xds-athenz', 'java11', 'publish', 'relocate' includeWithFlags ':xds-api', 'java', 'publish', 'relocate', 'javapgv', 'no_aggregation' includeWithFlags ':xds-validator', 'java', 'publish', 'relocate', 'no_aggregation' includeWithFlags ':xds-pgv-shaded', 'java', 'publish', 'relocate', 'no_aggregation' diff --git a/xds-api/src/main/java/com/linecorp/armeria/xds/api/SupportedFieldValidator.java b/xds-api/src/main/java/com/linecorp/armeria/xds/api/SupportedFieldValidator.java index 68e1f068d43..6d6cd8c21a6 100644 --- a/xds-api/src/main/java/com/linecorp/armeria/xds/api/SupportedFieldValidator.java +++ b/xds-api/src/main/java/com/linecorp/armeria/xds/api/SupportedFieldValidator.java @@ -115,7 +115,7 @@ public void validate(Message message) { @SuppressWarnings("unchecked") private void doValidate(Message message, String descriptorName, String path) { - if (unsupportedPackage(message.getDescriptorForType().getFile().getPackage())) { + if (isGoogleApi(message.getDescriptorForType().getFile().getPackage())) { return; } final Descriptors.Descriptor descriptor = message.getDescriptorForType(); @@ -160,8 +160,7 @@ private void validateFieldValue(FieldDescriptor fd, Object value, doValidate((Message) value, descriptorName, fieldPath); } } else if (fd.getJavaType() == FieldDescriptor.JavaType.ENUM) { - if (!unsupportedPackage(fd.getEnumType().getFile().getPackage()) && - value instanceof EnumValueDescriptor) { + if (value instanceof EnumValueDescriptor) { final EnumValueDescriptor ev = (EnumValueDescriptor) value; if (unsupportedEnumValue(ev)) { handler.handle(descriptorName, fieldPath, ev); @@ -177,8 +176,8 @@ private static boolean unsupportedEnumValue(EnumValueDescriptor ev) { return !supportedValues.isEmpty() && !supportedValues.contains(ev.getNumber()); } - private static boolean unsupportedPackage(String pkg) { - return !(pkg.startsWith("envoy.") || pkg.startsWith("xds.") || pkg.startsWith("armeria.")); + private static boolean isGoogleApi(String pkg) { + return pkg.startsWith("google.protobuf"); } private Set supportedFields(Descriptors.Descriptor descriptor) { diff --git a/xds-api/src/main/proto/armeria/xds/athenz/athenz_access_token.proto b/xds-api/src/main/proto/armeria/xds/athenz/athenz_access_token.proto new file mode 100644 index 00000000000..dc390ebd91a --- /dev/null +++ b/xds-api/src/main/proto/armeria/xds/athenz/athenz_access_token.proto @@ -0,0 +1,115 @@ +syntax = "proto3"; + +package jp.co.lycorp.ftd.athenz.v1; + +import "validate/validate.proto"; +import "envoy/type/matcher/v3/string.proto"; +import "armeria/xds/supported.proto"; + +// Athenz authorization metadata carried in xDS filter_metadata under the +// jp.co.lycorp.ftd.athenz.v1 namespace. +// - AccessTokenTarget (outbound): which domain/roles to fetch a token for. +// - AccessTokenConstraint (inbound): which domain to evaluate, plus an optional +// per-request action/resource mapping. + +// Request attributes usable in inbound mapping conditions and templates. +// HTTP-level only: these are available to both a proxy and a library client. +// Connection-level attributes (protocol/port/SNI/ALPN) are excluded because a +// library data plane cannot supply them. +enum WellKnownEndpointAttribute { + option (armeria.xds.supported.enum_value) = 1; + option (armeria.xds.supported.enum_value) = 2; + option (armeria.xds.supported.enum_value) = 3; + + WELL_KNOWN_ENDPOINT_ATTRIBUTE_UNSPECIFIED = 0; + WELL_KNOWN_ENDPOINT_ATTRIBUTE_HOST = 1; // :authority + WELL_KNOWN_ENDPOINT_ATTRIBUTE_METHOD = 2; // :method + WELL_KNOWN_ENDPOINT_ATTRIBUTE_PATH = 3; // :path, no query + // 4 was QUERY_STRING; deferred. Query attributes are out of v1 scope. +} + +message EndpointAttribute { + oneof attribute { + option (validate.required) = true; + option (armeria.xds.supported.oneof_field) = 1; + WellKnownEndpointAttribute well_known = 1 + [(validate.rules).enum = {defined_only: true not_in: 0}]; + option (armeria.xds.supported.oneof_field) = 2; + // Implementation-specific attribute name, documented per syntax_version. + string custom = 2 [(validate.rules).string = {min_len: 1}]; + } +} + +// Placeholders: ${host|method|path}, ${custom.}, +// ${match..}; $$ is a literal $. Unknown or missing -> rule fails. +message StringTemplate { + option (armeria.xds.supported.field) = 1; + string template = 1 [(validate.rules).string = {min_len: 1}]; +} + +message MappingString { + oneof string_specifier { + option (validate.required) = true; + option (armeria.xds.supported.oneof_field) = 1; + string literal = 1 [(validate.rules).string = {min_len: 1}]; + option (armeria.xds.supported.oneof_field) = 2; + StringTemplate template = 2 [(validate.rules).message = {required: true}]; + } +} + +message EndpointAttributeMatch { + option (armeria.xds.supported.field) = 1; + // Capture namespace referenced from templates as ${match..}. + string name = 1; + option (armeria.xds.supported.field) = 2; + EndpointAttribute attribute = 2 [(validate.rules).message = {required: true}]; + option (armeria.xds.supported.field) = 3; + envoy.type.matcher.v3.StringMatcher matcher = 3 + [(validate.rules).message = {required: true}]; +} + +// All conditions must match (empty = match all); rules are tried in order, +// first match wins. +message AssertionMappingRule { + option (armeria.xds.supported.field) = 1; + string name = 1; + option (armeria.xds.supported.field) = 2; + repeated EndpointAttributeMatch conditions = 2; + option (armeria.xds.supported.field) = 3; + MappingString action = 3 [(validate.rules).message = {required: true}]; + option (armeria.xds.supported.field) = 4; + MappingString resource = 4 [(validate.rules).message = {required: true}]; +} + +// No matching rule, or a rule whose action/resource fails to resolve -> deny. +message AssertionMapping { + option (armeria.xds.supported.field) = 1; + repeated AssertionMappingRule rules = 1 + [(validate.rules).repeated = {min_items: 1}]; +} + +// Outbound: token to acquire for a destination. Static per cluster; routing +// selects the cluster, so there is no per-request mapping here. +message AccessTokenTarget { + option (armeria.xds.supported.field) = 1; + string target_domain = 1 [(validate.rules).string = {min_len: 1}]; + option (armeria.xds.supported.field) = 2; + // Roles included in the token scope; usually one. + repeated string target_roles = 2 + [(validate.rules).repeated = {min_items: 1, items {string {min_len: 1}}}]; + option (armeria.xds.supported.field) = 3; + // Consumers reject versions they do not implement. + uint32 syntax_version = 3 [(validate.rules).uint32 = {gte: 1}]; +} + +// Inbound: token evaluation for a listener. +message AccessTokenConstraint { + option (armeria.xds.supported.field) = 1; + string constraint_domain = 1 [(validate.rules).string = {min_len: 1}]; + option (armeria.xds.supported.field) = 2; + uint32 syntax_version = 2 [(validate.rules).uint32 = {gte: 1}]; + option (armeria.xds.supported.field) = 3; + // Optional explicit rules for (action, resource). When unset, the default + // mapping applies: action = lower(method), resource = request path. + AssertionMapping assertion_mapping = 3; +} diff --git a/xds-api/src/main/proto/armeria/xds/athenz/athenz_filter_config.proto b/xds-api/src/main/proto/armeria/xds/athenz/athenz_filter_config.proto new file mode 100644 index 00000000000..548e6fb02c6 --- /dev/null +++ b/xds-api/src/main/proto/armeria/xds/athenz/athenz_filter_config.proto @@ -0,0 +1,25 @@ +syntax = "proto3"; + +package armeria.xds.athenz; + +option java_package = "com.linecorp.armeria.xds.athenz"; + +import "validate/validate.proto"; +import "armeria/xds/supported.proto"; +import "armeria/xds/athenz/athenz_access_token.proto"; + +// Outbound filter config: injects an Athenz access token into requests. +message AccessTokenTargetConfig { + option (armeria.xds.supported.field) = 1; + string zts_cluster_name = 1 [(validate.rules).string = {min_len: 1}]; + option (armeria.xds.supported.field) = 2; + jp.co.lycorp.ftd.athenz.v1.AccessTokenTarget access_token_target = 2 [(validate.rules).message = {required: true}]; +} + +// Inbound filter config: authorizes requests using Athenz access tokens. +message AccessTokenConstraintConfig { + option (armeria.xds.supported.field) = 1; + string zts_cluster_name = 1 [(validate.rules).string = {min_len: 1}]; + option (armeria.xds.supported.field) = 2; + jp.co.lycorp.ftd.athenz.v1.AccessTokenConstraint access_token_constraint = 2 [(validate.rules).message = {required: true}]; +} diff --git a/xds-athenz/build.gradle b/xds-athenz/build.gradle new file mode 100644 index 00000000000..b1ccd3c8e07 --- /dev/null +++ b/xds-athenz/build.gradle @@ -0,0 +1,4 @@ +dependencies { + api project(':xds') + api project(':athenz') +} diff --git a/xds-athenz/src/main/java/com/linecorp/armeria/xds/filter/athenz/AccessTokenConstraintFilterFactory.java b/xds-athenz/src/main/java/com/linecorp/armeria/xds/filter/athenz/AccessTokenConstraintFilterFactory.java new file mode 100644 index 00000000000..d72136297f1 --- /dev/null +++ b/xds-athenz/src/main/java/com/linecorp/armeria/xds/filter/athenz/AccessTokenConstraintFilterFactory.java @@ -0,0 +1,295 @@ +/* + * Copyright 2026 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.xds.filter.athenz; + +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import com.google.common.collect.ImmutableList; +import com.google.protobuf.Any; + +import com.linecorp.armeria.client.athenz.ZtsBaseClient; +import com.linecorp.armeria.common.CommonPools; +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.athenz.AthenzTokenHeader; +import com.linecorp.armeria.common.util.Exceptions; +import com.linecorp.armeria.server.DecoratingHttpServiceFunction; +import com.linecorp.armeria.server.ServiceRequestContext; +import com.linecorp.armeria.server.athenz.AccessCheckStatus; +import com.linecorp.armeria.server.athenz.AthenzAuthorizer; +import com.linecorp.armeria.server.athenz.AthenzPolicyConfig; +import com.linecorp.armeria.xds.athenz.AthenzFilterConfig.AccessTokenConstraintConfig; +import com.linecorp.armeria.xds.filter.FactoryContext; +import com.linecorp.armeria.xds.filter.HttpFilterFactory; +import com.linecorp.armeria.xds.filter.XdsHttpFilter; +import com.linecorp.armeria.xds.internal.XdsStringMatcher; +import com.linecorp.armeria.xds.stream.SnapshotStream; +import com.linecorp.armeria.xds.stream.Subscription; + +import io.envoyproxy.envoy.extensions.filters.network.http_connection_manager.v3.HttpFilter; +import jp.co.lycorp.ftd.athenz.v1.AthenzAccessToken.AccessTokenConstraint; +import jp.co.lycorp.ftd.athenz.v1.AthenzAccessToken.AssertionMappingRule; +import jp.co.lycorp.ftd.athenz.v1.AthenzAccessToken.EndpointAttribute; +import jp.co.lycorp.ftd.athenz.v1.AthenzAccessToken.EndpointAttribute.AttributeCase; +import jp.co.lycorp.ftd.athenz.v1.AthenzAccessToken.EndpointAttributeMatch; +import jp.co.lycorp.ftd.athenz.v1.AthenzAccessToken.WellKnownEndpointAttribute; + +final class AccessTokenConstraintFilterFactory implements HttpFilterFactory { + + private static final String NAME = "athenz.access_token_constraint"; + private static final String TYPE_URL = + "type.googleapis.com/armeria.xds.athenz.AccessTokenConstraintConfig"; + private static final List TYPE_URLS = ImmutableList.of(TYPE_URL); + + @Override + public String name() { + return NAME; + } + + @Override + public List typeUrls() { + return TYPE_URLS; + } + + @Override + @Nullable + public XdsHttpFilter create(HttpFilter httpFilter, Any config, FactoryContext context) { + throw new UnsupportedOperationException( + NAME + " requires reactive cluster subscription; use createStream()"); + } + + @Override + public SnapshotStream createStream(HttpFilter httpFilter, Any config, + FactoryContext context) { + final AccessTokenConstraintConfig filterConfig = + context.validator().unpack(config, AccessTokenConstraintConfig.class); + final String ztsClusterName = filterConfig.getZtsClusterName(); + final AccessTokenConstraint constraint = filterConfig.getAccessTokenConstraint(); + if (constraint.getSyntaxVersion() != 1) { + throw new IllegalArgumentException("Unsupported version: " + constraint.getSyntaxVersion()); + } + final String domain = constraint.getConstraintDomain(); + + return context.clusterStream(ztsClusterName).switchMapEager(clusterSnapshot -> { + // AthenzAuthorizer.build() is blocking (publicKeyStore.init() + zpeClient.init()), + // so we offload to the blocking executor. + return watcher -> { + CommonPools.blockingTaskExecutor().execute(() -> { + try { + final ZtsBaseClient ztsBaseClient = + new XdsZtsBaseClient(clusterSnapshot.preprocessor()); + final AthenzAuthorizer authorizer = + AthenzAuthorizer.builder(ztsBaseClient) + .policyConfig(new AthenzPolicyConfig(domain)) + .build(); + watcher.onUpdate(new InboundXdsHttpFilter(authorizer, constraint), null); + } catch (Exception e) { + watcher.onUpdate(null, e); + } + }); + return Subscription.noop(); + }; + }); + } + + private static final class InboundXdsHttpFilter implements XdsHttpFilter { + + private final AthenzAuthorizer authorizer; + private final List parsedRules; + + InboundXdsHttpFilter(AthenzAuthorizer authorizer, AccessTokenConstraint constraint) { + this.authorizer = authorizer; + if (constraint.hasAssertionMapping()) { + parsedRules = constraint.getAssertionMapping().getRulesList().stream() + .map(ParsedRule::new) + .collect(ImmutableList.toImmutableList()); + } else { + // Default mapping: action = lower(method), resource = request path. + parsedRules = ImmutableList.of(ParsedRule.defaultRule()); + } + } + + @Override + public DecoratingHttpServiceFunction serviceDecorator() { + return (delegate, ctx, req) -> { + final AthenzTokenHeader tokenHeader = AthenzTokenHeader.ofAccessToken(); + final String token = req.headers().get(tokenHeader.headerName(), ""); + if (token.isEmpty()) { + return HttpResponse.of(HttpStatus.UNAUTHORIZED); + } + final ActionResource actionResource = evaluateRules(ctx); + if (actionResource == null) { + return HttpResponse.of(HttpStatus.FORBIDDEN); + } + return HttpResponse.of( + authorizer.authorizeAsync(token, actionResource.resource, + actionResource.action) + .thenApply(status -> { + if (status == AccessCheckStatus.ALLOW) { + try { + return delegate.serve(ctx, req); + } catch (Exception e) { + return Exceptions.throwUnsafely(e); + } + } + return HttpResponse.of(HttpStatus.FORBIDDEN); + })); + }; + } + + @Nullable + private ActionResource evaluateRules(ServiceRequestContext ctx) { + for (ParsedRule rule : parsedRules) { + final ActionResource result = rule.evaluate(ctx); + if (result != null) { + return result; + } + } + return null; + } + } + + private static final class ParsedRule { + + private final List conditions; + @Nullable + private final MappingTemplate actionTemplate; + @Nullable + private final MappingTemplate resourceTemplate; + + ParsedRule(AssertionMappingRule rule) { + conditions = rule.getConditionsList().stream() + .map(ParsedCondition::new) + .collect(ImmutableList.toImmutableList()); + actionTemplate = MappingTemplate.of(rule.getAction()); + resourceTemplate = MappingTemplate.of(rule.getResource()); + } + + private ParsedRule() { + conditions = ImmutableList.of(); + actionTemplate = null; + resourceTemplate = null; + } + + static ParsedRule defaultRule() { + return new ParsedRule(); + } + + @Nullable + ActionResource evaluate(ServiceRequestContext ctx) { + if (actionTemplate == null || resourceTemplate == null) { + // Default mapping: action = lower(method), resource = path. + return new ActionResource(ctx.method().name().toLowerCase(Locale.ENGLISH), ctx.path()); + } + + final Map> captures = new HashMap<>(); + for (ParsedCondition condition : conditions) { + if (!condition.matchAndCapture(ctx, captures)) { + return null; + } + } + final String action = actionTemplate.resolve(ctx, captures); + final String resource = resourceTemplate.resolve(ctx, captures); + if (action == null || resource == null) { + return null; + } + return new ActionResource(action, resource); + } + } + + private static final class ParsedCondition { + + private final WellKnownEndpointAttribute wellKnown; + private final XdsStringMatcher matcher; + @Nullable + private final String captureName; + @Nullable + private final Pattern capturePattern; + + ParsedCondition(EndpointAttributeMatch condition) { + final EndpointAttribute attr = condition.getAttribute(); + if (attr.getAttributeCase() != AttributeCase.WELL_KNOWN) { + throw new IllegalArgumentException( + "Unsupported attribute case: " + attr.getAttributeCase()); + } + wellKnown = attr.getWellKnown(); + matcher = new XdsStringMatcher(condition.getMatcher()); + if (condition.getMatcher().hasSafeRegex() && !condition.getName().isEmpty()) { + captureName = condition.getName(); + capturePattern = Pattern.compile(condition.getMatcher().getSafeRegex().getRegex()); + } else { + captureName = null; + capturePattern = null; + } + } + + boolean matchAndCapture(ServiceRequestContext ctx, Map> captures) { + final String value = resolveWellKnown(wellKnown, ctx); + if (value == null || !matcher.match(value)) { + return false; + } + if (captureName != null) { + assert capturePattern != null; + final Matcher m = capturePattern.matcher(value); + if (!m.matches()) { + return false; + } + final ImmutableList.Builder groups = ImmutableList.builder(); + for (int i = 0; i <= m.groupCount(); i++) { + if (m.group(i) == null) { + return false; + } + groups.add(m.group(i)); + } + captures.put(captureName, groups.build()); + } + return true; + } + + @Nullable + private static String resolveWellKnown(WellKnownEndpointAttribute wellKnown, + ServiceRequestContext ctx) { + switch (wellKnown) { + case WELL_KNOWN_ENDPOINT_ATTRIBUTE_HOST: + return ctx.request().authority(); + case WELL_KNOWN_ENDPOINT_ATTRIBUTE_METHOD: + return ctx.method().name(); + case WELL_KNOWN_ENDPOINT_ATTRIBUTE_PATH: + return ctx.path(); + default: + throw new IllegalArgumentException( + "Unsupported well-known attribute: " + wellKnown); + } + } + } + + private static final class ActionResource { + final String action; + final String resource; + + ActionResource(String action, String resource) { + this.action = action; + this.resource = resource; + } + } +} diff --git a/xds-athenz/src/main/java/com/linecorp/armeria/xds/filter/athenz/AccessTokenConstraintFilterFactoryProvider.java b/xds-athenz/src/main/java/com/linecorp/armeria/xds/filter/athenz/AccessTokenConstraintFilterFactoryProvider.java new file mode 100644 index 00000000000..e050c962e72 --- /dev/null +++ b/xds-athenz/src/main/java/com/linecorp/armeria/xds/filter/athenz/AccessTokenConstraintFilterFactoryProvider.java @@ -0,0 +1,33 @@ +/* + * Copyright 2026 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.xds.filter.athenz; + +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.xds.XdsExtensionFactory; +import com.linecorp.armeria.xds.XdsExtensionFactoryProvider; + +/** + * Provides the Athenz access token constraint filter for xDS extension discovery. + */ +@UnstableApi +public final class AccessTokenConstraintFilterFactoryProvider implements XdsExtensionFactoryProvider { + + @Override + public XdsExtensionFactory newFactory() { + return new AccessTokenConstraintFilterFactory(); + } +} diff --git a/xds-athenz/src/main/java/com/linecorp/armeria/xds/filter/athenz/AccessTokenTargetFilterFactory.java b/xds-athenz/src/main/java/com/linecorp/armeria/xds/filter/athenz/AccessTokenTargetFilterFactory.java new file mode 100644 index 00000000000..8e86f29f671 --- /dev/null +++ b/xds-athenz/src/main/java/com/linecorp/armeria/xds/filter/athenz/AccessTokenTargetFilterFactory.java @@ -0,0 +1,127 @@ +/* + * Copyright 2026 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.xds.filter.athenz; + +import java.util.List; + +import com.google.common.collect.ImmutableList; +import com.google.protobuf.Any; + +import com.linecorp.armeria.client.ClientRequestContext; +import com.linecorp.armeria.client.DecoratingHttpClientFunction; +import com.linecorp.armeria.client.DecoratingRpcClientFunction; +import com.linecorp.armeria.client.athenz.AthenzTokenClient; +import com.linecorp.armeria.client.athenz.ZtsBaseClient; +import com.linecorp.armeria.common.HttpHeaderNames; +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.RpcResponse; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.util.Exceptions; +import com.linecorp.armeria.xds.athenz.AthenzFilterConfig.AccessTokenTargetConfig; +import com.linecorp.armeria.xds.filter.FactoryContext; +import com.linecorp.armeria.xds.filter.HttpFilterFactory; +import com.linecorp.armeria.xds.filter.XdsHttpFilter; +import com.linecorp.armeria.xds.stream.SnapshotStream; + +import io.envoyproxy.envoy.extensions.filters.network.http_connection_manager.v3.HttpFilter; +import jp.co.lycorp.ftd.athenz.v1.AthenzAccessToken.AccessTokenTarget; + +final class AccessTokenTargetFilterFactory implements HttpFilterFactory { + + private static final String NAME = "athenz.access_token_target"; + private static final String TYPE_URL = + "type.googleapis.com/armeria.xds.athenz.AccessTokenTargetConfig"; + private static final List TYPE_URLS = ImmutableList.of(TYPE_URL); + + @Override + public String name() { + return NAME; + } + + @Override + public List typeUrls() { + return TYPE_URLS; + } + + @Override + @Nullable + public XdsHttpFilter create(HttpFilter httpFilter, Any config, FactoryContext context) { + throw new UnsupportedOperationException( + NAME + " requires reactive cluster subscription; use createStream()"); + } + + @Override + public SnapshotStream createStream(HttpFilter httpFilter, Any config, + FactoryContext context) { + final AccessTokenTargetConfig filterConfig = + context.validator().unpack(config, AccessTokenTargetConfig.class); + final String ztsClusterName = filterConfig.getZtsClusterName(); + final AccessTokenTarget target = filterConfig.getAccessTokenTarget(); + if (target.getSyntaxVersion() != 1) { + throw new IllegalArgumentException("Unsupported version: " + target.getSyntaxVersion()); + } + + return context.clusterStream(ztsClusterName).map(clusterSnapshot -> { + final ZtsBaseClient ztsBaseClient = new XdsZtsBaseClient(clusterSnapshot.preprocessor()); + final AthenzTokenClient tokenClient = + AthenzTokenClient.builder(ztsBaseClient) + .domainName(target.getTargetDomain()) + .roleNames(target.getTargetRolesList()) + .build(); + return new OutboundXdsHttpFilter(tokenClient); + }); + } + + private static final class OutboundXdsHttpFilter implements XdsHttpFilter { + + private final AthenzTokenClient tokenClient; + + private OutboundXdsHttpFilter(AthenzTokenClient tokenClient) { + this.tokenClient = tokenClient; + } + + private static void setToken(ClientRequestContext ctx, String token) { + ctx.setAdditionalRequestHeader(HttpHeaderNames.AUTHORIZATION, "Bearer " + token); + } + + @Override + public DecoratingHttpClientFunction httpDecorator() { + return (delegate, ctx, req) -> HttpResponse.of( + tokenClient.getToken().thenApply(token -> { + setToken(ctx, token); + try { + return delegate.execute(ctx, req); + } catch (Exception e) { + return Exceptions.throwUnsafely(e); + } + })); + } + + @Override + public DecoratingRpcClientFunction rpcDecorator() { + return (delegate, ctx, req) -> RpcResponse.from( + tokenClient.getToken().thenApply(token -> { + setToken(ctx, token); + try { + return delegate.execute(ctx, req); + } catch (Exception e) { + return Exceptions.throwUnsafely(e); + } + })); + } + } +} diff --git a/xds-athenz/src/main/java/com/linecorp/armeria/xds/filter/athenz/AccessTokenTargetFilterFactoryProvider.java b/xds-athenz/src/main/java/com/linecorp/armeria/xds/filter/athenz/AccessTokenTargetFilterFactoryProvider.java new file mode 100644 index 00000000000..34e40505ea2 --- /dev/null +++ b/xds-athenz/src/main/java/com/linecorp/armeria/xds/filter/athenz/AccessTokenTargetFilterFactoryProvider.java @@ -0,0 +1,33 @@ +/* + * Copyright 2026 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.xds.filter.athenz; + +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.xds.XdsExtensionFactory; +import com.linecorp.armeria.xds.XdsExtensionFactoryProvider; + +/** + * Provides the Athenz access token target filter for xDS extension discovery. + */ +@UnstableApi +public final class AccessTokenTargetFilterFactoryProvider implements XdsExtensionFactoryProvider { + + @Override + public XdsExtensionFactory newFactory() { + return new AccessTokenTargetFilterFactory(); + } +} diff --git a/xds-athenz/src/main/java/com/linecorp/armeria/xds/filter/athenz/AthenzTypeRegistryPackageProvider.java b/xds-athenz/src/main/java/com/linecorp/armeria/xds/filter/athenz/AthenzTypeRegistryPackageProvider.java new file mode 100644 index 00000000000..ee1c94168d5 --- /dev/null +++ b/xds-athenz/src/main/java/com/linecorp/armeria/xds/filter/athenz/AthenzTypeRegistryPackageProvider.java @@ -0,0 +1,34 @@ +/* + * Copyright 2026 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.xds.filter.athenz; + +import com.google.common.collect.ImmutableList; + +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.xds.XdsTypeRegistryPackageProvider; + +/** + * Provides the Athenz protobuf package for xDS type registry discovery. + */ +@UnstableApi +public final class AthenzTypeRegistryPackageProvider implements XdsTypeRegistryPackageProvider { + + @Override + public Iterable packages() { + return ImmutableList.of("jp.co.lycorp.ftd.athenz.v1", "com.linecorp.armeria.xds.athenz"); + } +} diff --git a/xds-athenz/src/main/java/com/linecorp/armeria/xds/filter/athenz/MappingTemplate.java b/xds-athenz/src/main/java/com/linecorp/armeria/xds/filter/athenz/MappingTemplate.java new file mode 100644 index 00000000000..ef1270a0dac --- /dev/null +++ b/xds-athenz/src/main/java/com/linecorp/armeria/xds/filter/athenz/MappingTemplate.java @@ -0,0 +1,180 @@ +/* + * Copyright 2026 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.xds.filter.athenz; + +import static com.google.common.base.Preconditions.checkArgument; + +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import com.google.common.collect.ImmutableList; + +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.server.ServiceRequestContext; + +import jp.co.lycorp.ftd.athenz.v1.AthenzAccessToken.MappingString; + +/** + * Pre-parsed template from a {@link MappingString}. + * + *

Supported placeholders: {@code ${host}}, {@code ${method}}, {@code ${path}}, + * {@code ${match..}}. + */ +final class MappingTemplate { + + private static final Pattern PLACEHOLDER = Pattern.compile("\\$\\{([^}]+)}"); + + private final List segments; + + private MappingTemplate(List segments) { + this.segments = segments; + } + + static MappingTemplate of(MappingString mappingString) { + switch (mappingString.getStringSpecifierCase()) { + case LITERAL: + return literal(mappingString.getLiteral()); + case TEMPLATE: + return template(mappingString.getTemplate().getTemplate()); + default: + throw new IllegalArgumentException( + "Unsupported MappingString case: " + mappingString.getStringSpecifierCase()); + } + } + + private static MappingTemplate literal(String value) { + return new MappingTemplate(ImmutableList.of(new LiteralSegment(value))); + } + + private static MappingTemplate template(String template) { + final ImmutableList.Builder segments = ImmutableList.builder(); + final Matcher m = PLACEHOLDER.matcher(template); + int lastEnd = 0; + while (m.find()) { + if (m.start() > lastEnd) { + segments.add(new LiteralSegment(template.substring(lastEnd, m.start()))); + } + segments.add(parsePlaceholder(m.group(1))); + lastEnd = m.end(); + } + if (lastEnd < template.length()) { + segments.add(new LiteralSegment(template.substring(lastEnd))); + } + return new MappingTemplate(segments.build()); + } + + private static Segment parsePlaceholder(String key) { + switch (key) { + case "host": + return HostSegment.INSTANCE; + case "method": + return MethodSegment.INSTANCE; + case "path": + return PathSegment.INSTANCE; + default: + // Expected format: ${match..} + final String[] parts = key.split("\\.", 3); + if (parts.length == 3 && "match".equals(parts[0])) { + final int index = Integer.parseInt(parts[2]); + return new CaptureSegment(parts[1], index); + } + throw new IllegalArgumentException("Unknown placeholder: ${" + key + '}'); + } + } + + @Nullable + String resolve(ServiceRequestContext ctx, Map> captures) { + final StringBuilder sb = new StringBuilder(); + for (Segment segment : segments) { + final String value = segment.resolve(ctx, captures); + if (value == null) { + return null; + } + sb.append(value); + } + return sb.toString(); + } + + private interface Segment { + @Nullable + String resolve(ServiceRequestContext ctx, Map> captures); + } + + private static final class LiteralSegment implements Segment { + private final String value; + + LiteralSegment(String value) { + this.value = value; + } + + @Override + public String resolve(ServiceRequestContext ctx, Map> captures) { + return value; + } + } + + private static final class HostSegment implements Segment { + static final HostSegment INSTANCE = new HostSegment(); + + @Nullable + @Override + public String resolve(ServiceRequestContext ctx, Map> captures) { + return ctx.request().authority(); + } + } + + private static final class MethodSegment implements Segment { + static final MethodSegment INSTANCE = new MethodSegment(); + + @Override + public String resolve(ServiceRequestContext ctx, Map> captures) { + return ctx.method().name(); + } + } + + private static final class PathSegment implements Segment { + static final PathSegment INSTANCE = new PathSegment(); + + @Override + public String resolve(ServiceRequestContext ctx, Map> captures) { + return ctx.path(); + } + } + + private static final class CaptureSegment implements Segment { + private final String name; + private final int index; + + CaptureSegment(String name, int index) { + checkArgument(index >= 0, "index: %s (expected: >= 0) for name: %s", index, name); + this.name = name; + this.index = index; + } + + @Override + @Nullable + public String resolve(ServiceRequestContext ctx, Map> captures) { + final List groups = captures.get(name); + if (groups == null || index >= groups.size()) { + return null; + } + return groups.get(index); + } + } +} diff --git a/xds-athenz/src/main/java/com/linecorp/armeria/xds/filter/athenz/XdsZtsBaseClient.java b/xds-athenz/src/main/java/com/linecorp/armeria/xds/filter/athenz/XdsZtsBaseClient.java new file mode 100644 index 00000000000..24c1811655a --- /dev/null +++ b/xds-athenz/src/main/java/com/linecorp/armeria/xds/filter/athenz/XdsZtsBaseClient.java @@ -0,0 +1,46 @@ +/* + * Copyright 2026 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.xds.filter.athenz; + +import java.util.function.Consumer; + +import com.linecorp.armeria.client.HttpPreprocessor; +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.client.WebClientBuilder; +import com.linecorp.armeria.client.athenz.ZtsBaseClient; + +final class XdsZtsBaseClient implements ZtsBaseClient { + + private final WebClient webClient; + + XdsZtsBaseClient(HttpPreprocessor preprocessor) { + webClient = WebClient.of(preprocessor, "/zts/v1"); + } + + @Override + public WebClient webClient() { + return webClient; + } + + @Override + public WebClient webClient(Consumer configurer) { + throw new UnsupportedOperationException(); + } + + @Override + public void close() {} +} diff --git a/xds-athenz/src/main/java/com/linecorp/armeria/xds/filter/athenz/package-info.java b/xds-athenz/src/main/java/com/linecorp/armeria/xds/filter/athenz/package-info.java new file mode 100644 index 00000000000..7492785974e --- /dev/null +++ b/xds-athenz/src/main/java/com/linecorp/armeria/xds/filter/athenz/package-info.java @@ -0,0 +1,25 @@ +/* + * Copyright 2026 LY Corporation + * + * LY Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +/** + * Athenz access token filter integration for xDS. + */ +@NonNullByDefault +@UnstableApi +package com.linecorp.armeria.xds.filter.athenz; + +import com.linecorp.armeria.common.annotation.NonNullByDefault; +import com.linecorp.armeria.common.annotation.UnstableApi; diff --git a/xds-athenz/src/main/resources/META-INF/services/com.linecorp.armeria.xds.XdsExtensionFactoryProvider b/xds-athenz/src/main/resources/META-INF/services/com.linecorp.armeria.xds.XdsExtensionFactoryProvider new file mode 100644 index 00000000000..921f540e0b1 --- /dev/null +++ b/xds-athenz/src/main/resources/META-INF/services/com.linecorp.armeria.xds.XdsExtensionFactoryProvider @@ -0,0 +1,2 @@ +com.linecorp.armeria.xds.filter.athenz.AccessTokenTargetFilterFactoryProvider +com.linecorp.armeria.xds.filter.athenz.AccessTokenConstraintFilterFactoryProvider diff --git a/xds-athenz/src/main/resources/META-INF/services/com.linecorp.armeria.xds.XdsTypeRegistryPackageProvider b/xds-athenz/src/main/resources/META-INF/services/com.linecorp.armeria.xds.XdsTypeRegistryPackageProvider new file mode 100644 index 00000000000..31a0d6f0662 --- /dev/null +++ b/xds-athenz/src/main/resources/META-INF/services/com.linecorp.armeria.xds.XdsTypeRegistryPackageProvider @@ -0,0 +1 @@ +com.linecorp.armeria.xds.filter.athenz.AthenzTypeRegistryPackageProvider diff --git a/xds/src/main/java/com/linecorp/armeria/xds/CertificateValidationContextSnapshot.java b/xds/src/main/java/com/linecorp/armeria/xds/CertificateValidationContextSnapshot.java index f8c8eb6ec75..691d9332e15 100644 --- a/xds/src/main/java/com/linecorp/armeria/xds/CertificateValidationContextSnapshot.java +++ b/xds/src/main/java/com/linecorp/armeria/xds/CertificateValidationContextSnapshot.java @@ -34,6 +34,7 @@ import com.linecorp.armeria.common.TlsPeerVerifierFactory; import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.xds.internal.XdsStringMatcher; import io.envoyproxy.envoy.extensions.transport_sockets.tls.v3.CertificateValidationContext; import io.envoyproxy.envoy.extensions.transport_sockets.tls.v3.SubjectAltNameMatcher; @@ -66,7 +67,7 @@ public final class CertificateValidationContextSnapshot implements Snapshot fromQueryParamsMatchers(List params.contains(matcher.getName()) == matcher.getPresentMatch(); break; case STRING_MATCH: - final StringMatcherImpl stringMatcher = new StringMatcherImpl(matcher.getStringMatch()); + final XdsStringMatcher stringMatcher = new XdsStringMatcher(matcher.getStringMatch()); predicate = params -> { final String value = params.get(matcher.getName()); if (value == null) { @@ -180,8 +181,8 @@ static List fromHeaderMatchers(List headerMatc }; break; case STRING_MATCH: - final StringMatcherImpl stringMatcher = - new StringMatcherImpl(headerMatcher.getStringMatch()); + final XdsStringMatcher stringMatcher = + new XdsStringMatcher(headerMatcher.getStringMatch()); matcher = headers -> { final List allHeaders = headers.getAll(headerMatcher.getName()); if (allHeaders.isEmpty()) { @@ -231,7 +232,7 @@ static class PathMatcherImpl { .setPrefix(routeMatch.getPrefix()) .setIgnoreCase(!caseSensitive) .build(); - final StringMatcherImpl prefixMatcherImpl = new StringMatcherImpl(prefixMatcher); + final XdsStringMatcher prefixMatcherImpl = new XdsStringMatcher(prefixMatcher); predicate = ctx -> prefixMatcherImpl.match(ctx.path()); break; case PATH: @@ -239,14 +240,14 @@ static class PathMatcherImpl { .setExact(routeMatch.getPath()) .setIgnoreCase(!caseSensitive) .build(); - final StringMatcherImpl pathMatcherImpl = new StringMatcherImpl(pathMatcher); + final XdsStringMatcher pathMatcherImpl = new XdsStringMatcher(pathMatcher); predicate = ctx -> pathMatcherImpl.match(ctx.path()); break; case SAFE_REGEX: final StringMatcher regexMatcher = StringMatcher.newBuilder() .setSafeRegex(routeMatch.getSafeRegex()) .build(); - final StringMatcherImpl regexMatcherImpl = new StringMatcherImpl(regexMatcher); + final XdsStringMatcher regexMatcherImpl = new XdsStringMatcher(regexMatcher); predicate = ctx -> regexMatcherImpl.match(ctx.path()); break; case CONNECT_MATCHER: @@ -258,8 +259,8 @@ static class PathMatcherImpl { .setPrefix(routeMatch.getPathSeparatedPrefix()) .setIgnoreCase(!caseSensitive) .build(); - final StringMatcherImpl separatedPrefixMatcherImpl = - new StringMatcherImpl(separatedPrefixMatcher); + final XdsStringMatcher separatedPrefixMatcherImpl = + new XdsStringMatcher(separatedPrefixMatcher); predicate = ctx -> { final String path = ctx.path(); final String pathSeparatedPrefix = routeMatch.getPathSeparatedPrefix(); diff --git a/xds/src/main/java/com/linecorp/armeria/xds/SanMatcher.java b/xds/src/main/java/com/linecorp/armeria/xds/SanMatcher.java index 4b1ccfbfa35..9aef7902e9c 100644 --- a/xds/src/main/java/com/linecorp/armeria/xds/SanMatcher.java +++ b/xds/src/main/java/com/linecorp/armeria/xds/SanMatcher.java @@ -26,15 +26,16 @@ import com.google.common.base.Ascii; import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.xds.internal.XdsStringMatcher; import io.envoyproxy.envoy.extensions.transport_sockets.tls.v3.SubjectAltNameMatcher; final class SanMatcher { private final SubjectAltNameMatcher.SanType type; - private final StringMatcherImpl matcher; + private final XdsStringMatcher matcher; - SanMatcher(SubjectAltNameMatcher.SanType type, StringMatcherImpl matcher) { + SanMatcher(SubjectAltNameMatcher.SanType type, XdsStringMatcher matcher) { this.type = Objects.requireNonNull(type, "type"); this.matcher = Objects.requireNonNull(matcher, "matcher"); } diff --git a/xds/src/main/java/com/linecorp/armeria/xds/StringMatcherImpl.java b/xds/src/main/java/com/linecorp/armeria/xds/internal/XdsStringMatcher.java similarity index 92% rename from xds/src/main/java/com/linecorp/armeria/xds/StringMatcherImpl.java rename to xds/src/main/java/com/linecorp/armeria/xds/internal/XdsStringMatcher.java index ffcc31d6462..abc9c9976da 100644 --- a/xds/src/main/java/com/linecorp/armeria/xds/StringMatcherImpl.java +++ b/xds/src/main/java/com/linecorp/armeria/xds/internal/XdsStringMatcher.java @@ -13,7 +13,7 @@ * License for the specific language governing permissions and limitations * under the License. */ -package com.linecorp.armeria.xds; +package com.linecorp.armeria.xds.internal; import java.util.Objects; import java.util.function.Predicate; @@ -28,7 +28,7 @@ import io.envoyproxy.envoy.type.matcher.v3.StringMatcher; import io.envoyproxy.envoy.type.matcher.v3.StringMatcher.MatchPatternCase; -class StringMatcherImpl { +public final class XdsStringMatcher { private final boolean ignoreCase; private final MatchPatternCase patternCase; @@ -38,7 +38,7 @@ class StringMatcherImpl { @Nullable private final String patternValue; - StringMatcherImpl(StringMatcher stringMatcher) { + public XdsStringMatcher(StringMatcher stringMatcher) { ignoreCase = stringMatcher.getIgnoreCase(); patternCase = stringMatcher.getMatchPatternCase(); switch (patternCase) { @@ -99,7 +99,7 @@ class StringMatcherImpl { } } - boolean match(@Nullable String input) { + public boolean match(@Nullable String input) { if (input == null) { return false; } @@ -109,16 +109,16 @@ boolean match(@Nullable String input) { return predicate.test(input); } - boolean ignoreCase() { + public boolean ignoreCase() { return ignoreCase; } - boolean isExact() { + public boolean isExact() { return patternCase == MatchPatternCase.EXACT; } @Nullable - String exact() { + public String exact() { return exact; } @@ -143,10 +143,10 @@ public boolean equals(Object obj) { if (this == obj) { return true; } - if (!(obj instanceof StringMatcherImpl)) { + if (!(obj instanceof XdsStringMatcher)) { return false; } - final StringMatcherImpl that = (StringMatcherImpl) obj; + final XdsStringMatcher that = (XdsStringMatcher) obj; return patternCase == that.patternCase && ignoreCase == that.ignoreCase && Objects.equals(patternValue, that.patternValue);