diff --git a/authz/build.gradle b/authz/build.gradle index ce327f14e1..50084752d0 100644 --- a/authz/build.gradle +++ b/authz/build.gradle @@ -33,12 +33,6 @@ tasks.named("jar").configure { classifier = 'original' } -// TODO(ashithasantosh): Remove javadoc exclusion on adding authorization -// interceptor implementations. -tasks.named("javadoc").configure { - exclude "io/grpc/authz/*" -} - tasks.named("shadowJar").configure { classifier = null dependencies { @@ -52,6 +46,12 @@ tasks.named("shadowJar").configure { relocate 'com.google.api.expr', 'io.grpc.xds.shaded.com.google.api.expr' } +tasks.named("compileJava").configure { + it.options.compilerArgs += [ + "-Xlint:-processing", + ] +} + publishing { publications { maven(MavenPublication) { diff --git a/authz/src/main/java/io/grpc/authz/AuthorizationServerInterceptor.java b/authz/src/main/java/io/grpc/authz/AuthorizationServerInterceptor.java new file mode 100644 index 0000000000..2e39866609 --- /dev/null +++ b/authz/src/main/java/io/grpc/authz/AuthorizationServerInterceptor.java @@ -0,0 +1,74 @@ +/* + * Copyright 2022 The gRPC Authors + * + * Licensed 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 + * + * http://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 io.grpc.authz; + +import static com.google.common.base.Preconditions.checkNotNull; + +import io.envoyproxy.envoy.config.rbac.v3.RBAC; +import io.grpc.ExperimentalApi; +import io.grpc.InternalServerInterceptors; +import io.grpc.Metadata; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; +import io.grpc.xds.InternalRbacFilter; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + * Authorization server interceptor for static policy. The class will get + * + * gRPC Authorization policy as a JSON string during initialization. + * This policy will be translated to Envoy RBAC policies to make + * authorization decisions. The policy cannot be changed once created. + */ +@ExperimentalApi("https://github.com/grpc/grpc-java/issues/9746") +public final class AuthorizationServerInterceptor implements ServerInterceptor { + private final List interceptors = new ArrayList<>(); + + private AuthorizationServerInterceptor(String authorizationPolicy) + throws IOException { + List rbacs = AuthorizationPolicyTranslator.translate(authorizationPolicy); + if (rbacs == null || rbacs.isEmpty() || rbacs.size() > 2) { + throw new IllegalArgumentException("Failed to translate authorization policy"); + } + for (RBAC rbac: rbacs) { + interceptors.add( + InternalRbacFilter.createInterceptor( + io.envoyproxy.envoy.extensions.filters.http.rbac.v3.RBAC.newBuilder() + .setRules(rbac).build())); + } + } + + @Override + public ServerCall.Listener interceptCall( + ServerCall call, Metadata headers, + ServerCallHandler next) { + for (ServerInterceptor interceptor: interceptors) { + next = InternalServerInterceptors.interceptCallHandlerCreate(interceptor, next); + } + return next.startCall(call, headers); + } + + // Static method that creates an AuthorizationServerInterceptor. + public static AuthorizationServerInterceptor create(String authorizationPolicy) + throws IOException { + checkNotNull(authorizationPolicy, "authorizationPolicy"); + return new AuthorizationServerInterceptor(authorizationPolicy); + } +} diff --git a/authz/src/test/java/io/grpc/authz/AuthorizationEnd2EndTest.java b/authz/src/test/java/io/grpc/authz/AuthorizationEnd2EndTest.java new file mode 100644 index 0000000000..423c27bee5 --- /dev/null +++ b/authz/src/test/java/io/grpc/authz/AuthorizationEnd2EndTest.java @@ -0,0 +1,375 @@ +/* + * Copyright 2022 The gRPC Authors + * + * Licensed 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 + * + * http://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 io.grpc.authz; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.fail; + +import io.grpc.ChannelCredentials; +import io.grpc.Grpc; +import io.grpc.InsecureChannelCredentials; +import io.grpc.InsecureServerCredentials; +import io.grpc.ManagedChannel; +import io.grpc.Server; +import io.grpc.ServerCredentials; +import io.grpc.StatusRuntimeException; +import io.grpc.TlsChannelCredentials; +import io.grpc.TlsServerCredentials; +import io.grpc.TlsServerCredentials.ClientAuth; +import io.grpc.internal.testing.TestUtils; +import io.grpc.stub.StreamObserver; +import io.grpc.testing.protobuf.SimpleRequest; +import io.grpc.testing.protobuf.SimpleResponse; +import io.grpc.testing.protobuf.SimpleServiceGrpc; + +import java.io.File; +import org.junit.After; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class AuthorizationEnd2EndTest { + public static final String SERVER_0_KEY_FILE = "server0.key"; + public static final String SERVER_0_PEM_FILE = "server0.pem"; + public static final String CLIENT_0_KEY_FILE = "client.key"; + public static final String CLIENT_0_PEM_FILE = "client.pem"; + public static final String CA_PEM_FILE = "ca.pem"; + + private Server server; + private ManagedChannel channel; + + private void initServerWithStaticAuthz( + String authorizationPolicy, ServerCredentials serverCredentials) throws Exception { + AuthorizationServerInterceptor authzInterceptor = + AuthorizationServerInterceptor.create(authorizationPolicy); + server = Grpc.newServerBuilderForPort(0, serverCredentials) + .addService(new SimpleServiceImpl()) + .intercept(authzInterceptor) + .build() + .start(); + } + + private SimpleServiceGrpc.SimpleServiceBlockingStub getStub() { + channel = + Grpc.newChannelBuilderForAddress( + "localhost", server.getPort(), InsecureChannelCredentials.create()) + .build(); + return SimpleServiceGrpc.newBlockingStub(channel); + } + + private SimpleServiceGrpc.SimpleServiceBlockingStub getStub( + ChannelCredentials channelCredentials) { + channel = Grpc.newChannelBuilderForAddress( + "localhost", server.getPort(), channelCredentials) + .overrideAuthority("foo.test.google.com.au") + .build(); + return SimpleServiceGrpc.newBlockingStub(channel); + } + + @After + public void tearDown() { + if (server != null) { + server.shutdown(); + } + if (channel != null) { + channel.shutdown(); + } + } + + @Test + public void staticAuthzAllowsRpcNoMatchInDenyMatchInAllowTest() throws Exception { + String policy = "{" + + " \"name\" : \"authz\" ," + + " \"deny_rules\": [" + + " {" + + " \"name\": \"deny_UnaryRpc\"," + + " \"request\": {" + + " \"paths\": [" + + " \"*/UnaryRpc\"" + + " ]," + + " \"headers\": [" + + " {" + + " \"key\": \"dev-path\"," + + " \"values\": [\"/dev/path/*\"]" + + " }" + + " ]" + + " }" + + " }" + + " ]," + + " \"allow_rules\": [" + + " {" + + " \"name\": \"allow_all\"" + + " }" + + " ]" + + "}"; + initServerWithStaticAuthz(policy, InsecureServerCredentials.create()); + getStub().unaryRpc(SimpleRequest.getDefaultInstance()); + } + + @Test + public void staticAuthzDeniesRpcNoMatchInDenyAndAllowTest() throws Exception { + String policy = "{" + + " \"name\" : \"authz\" ," + + " \"deny_rules\": [" + + " {" + + " \"name\": \"deny_foo\"," + + " \"source\": {" + + " \"principals\": [" + + " \"foo\"" + + " ]" + + " }" + + " }" + + " ]," + + " \"allow_rules\": [" + + " {" + + " \"name\": \"allow_ClientStreamingRpc\"," + + " \"request\": {" + + " \"paths\": [" + + " \"*/ClientStreamingRpc\"" + + " ]" + + " }" + + " }" + + " ]" + + "}"; + initServerWithStaticAuthz(policy, InsecureServerCredentials.create()); + try { + getStub().unaryRpc(SimpleRequest.getDefaultInstance()); + fail("exception expected"); + } catch (StatusRuntimeException sre) { + assertThat(sre).hasMessageThat().isEqualTo( + "PERMISSION_DENIED: Access Denied"); + } catch (Exception e) { + throw new AssertionError("the test failed ", e); + } + } + + @Test + public void staticAuthzDeniesRpcMatchInDenyAndAllowTest() throws Exception { + String policy = "{" + + " \"name\" : \"authz\" ," + + " \"deny_rules\": [" + + " {" + + " \"name\": \"deny_UnaryRpc\"," + + " \"request\": {" + + " \"paths\": [" + + " \"*/UnaryRpc\"" + + " ]" + + " }" + + " }" + + " ]," + + " \"allow_rules\": [" + + " {" + + " \"name\": \"allow_UnaryRpc\"," + + " \"request\": {" + + " \"paths\": [" + + " \"*/UnaryRpc\"" + + " ]" + + " }" + + " }" + + " ]" + + "}"; + initServerWithStaticAuthz(policy, InsecureServerCredentials.create()); + try { + getStub().unaryRpc(SimpleRequest.getDefaultInstance()); + fail("exception expected"); + } catch (StatusRuntimeException sre) { + assertThat(sre).hasMessageThat().isEqualTo( + "PERMISSION_DENIED: Access Denied"); + } catch (Exception e) { + throw new AssertionError("the test failed ", e); + } + } + + @Test + public void staticAuthzDeniesRpcMatchInDenyNoMatchInAllowTest() throws Exception { + String policy = "{" + + " \"name\" : \"authz\" ," + + " \"deny_rules\": [" + + " {" + + " \"name\": \"deny_UnaryRpc\"," + + " \"request\": {" + + " \"paths\": [" + + " \"*/UnaryRpc\"" + + " ]" + + " }" + + " }" + + " ]," + + " \"allow_rules\": [" + + " {" + + " \"name\": \"allow_ClientStreamingRpc\"," + + " \"request\": {" + + " \"paths\": [" + + " \"*/ClientStreamingRpc\"" + + " ]" + + " }" + + " }" + + " ]" + + "}"; + initServerWithStaticAuthz(policy, InsecureServerCredentials.create()); + try { + getStub().unaryRpc(SimpleRequest.getDefaultInstance()); + fail("exception expected"); + } catch (StatusRuntimeException sre) { + assertThat(sre).hasMessageThat().isEqualTo( + "PERMISSION_DENIED: Access Denied"); + } catch (Exception e) { + throw new AssertionError("the test failed ", e); + } + } + + @Test + public void staticAuthzAllowsRpcEmptyDenyMatchInAllowTest() throws Exception { + String policy = "{" + + " \"name\" : \"authz\" ," + + " \"allow_rules\": [" + + " {" + + " \"name\": \"allow_UnaryRpc\"," + + " \"request\": {" + + " \"paths\": [" + + " \"*/UnaryRpc\"" + + " ]" + + " }" + + " }" + + " ]" + + "}"; + initServerWithStaticAuthz(policy, InsecureServerCredentials.create()); + getStub().unaryRpc(SimpleRequest.getDefaultInstance()); + } + + @Test + public void staticAuthzDeniesRpcEmptyDenyNoMatchInAllowTest() throws Exception { + String policy = "{" + + " \"name\" : \"authz\" ," + + " \"allow_rules\": [" + + " {" + + " \"name\": \"allow_ClientStreamingRpc\"," + + " \"request\": {" + + " \"paths\": [" + + " \"*/ClientStreamingRpc\"" + + " ]" + + " }" + + " }" + + " ]" + + "}"; + initServerWithStaticAuthz(policy, InsecureServerCredentials.create()); + try { + getStub().unaryRpc(SimpleRequest.getDefaultInstance()); + fail("exception expected"); + } catch (StatusRuntimeException sre) { + assertThat(sre).hasMessageThat().isEqualTo( + "PERMISSION_DENIED: Access Denied"); + } catch (Exception e) { + throw new AssertionError("the test failed ", e); + } + } + + @Test + public void staticAuthzDeniesRpcWithPrincipalsFieldOnUnauthenticatedConnectionTest() + throws Exception { + String policy = "{" + + " \"name\" : \"authz\" ," + + " \"allow_rules\": [" + + " {" + + " \"name\": \"allow_authenticated\"," + + " \"source\": {" + + " \"principals\": [\"*\", \"\"]" + + " }" + + " }" + + " ]" + + "}"; + initServerWithStaticAuthz(policy, InsecureServerCredentials.create()); + try { + getStub().unaryRpc(SimpleRequest.getDefaultInstance()); + fail("exception expected"); + } catch (StatusRuntimeException sre) { + assertThat(sre).hasMessageThat().isEqualTo( + "PERMISSION_DENIED: Access Denied"); + } catch (Exception e) { + throw new AssertionError("the test failed ", e); + } + } + + @Test + public void staticAuthzAllowsRpcWithPrincipalsFieldOnMtlsAuthenticatedConnectionTest() + throws Exception { + File caCertFile = TestUtils.loadCert(CA_PEM_FILE); + File serverKey0File = TestUtils.loadCert(SERVER_0_KEY_FILE); + File serverCert0File = TestUtils.loadCert(SERVER_0_PEM_FILE); + File clientKey0File = TestUtils.loadCert(CLIENT_0_KEY_FILE); + File clientCert0File = TestUtils.loadCert(CLIENT_0_PEM_FILE); + String policy = "{" + + " \"name\" : \"authz\" ," + + " \"allow_rules\": [" + + " {" + + " \"name\": \"allow_mtls\"," + + " \"source\": {" + + " \"principals\": [\"*\"]" + + " }" + + " }" + + " ]" + + "}"; + ServerCredentials serverCredentials = TlsServerCredentials.newBuilder() + .keyManager(serverCert0File, serverKey0File) + .trustManager(caCertFile) + .clientAuth(ClientAuth.REQUIRE) + .build(); + initServerWithStaticAuthz(policy, serverCredentials); + ChannelCredentials channelCredentials = TlsChannelCredentials.newBuilder() + .keyManager(clientCert0File, clientKey0File) + .trustManager(caCertFile) + .build(); + getStub(channelCredentials).unaryRpc(SimpleRequest.getDefaultInstance()); + } + + @Test + public void staticAuthzAllowsRpcWithPrincipalsFieldOnTlsAuthenticatedConnectionTest() + throws Exception { + File caCertFile = TestUtils.loadCert(CA_PEM_FILE); + File serverKey0File = TestUtils.loadCert(SERVER_0_KEY_FILE); + File serverCert0File = TestUtils.loadCert(SERVER_0_PEM_FILE); + String policy = "{" + + " \"name\" : \"authz\" ," + + " \"allow_rules\": [" + + " {" + + " \"name\": \"allow_tls\"," + + " \"source\": {" + + " \"principals\": [\"\"]" + + " }" + + " }" + + " ]" + + "}"; + ServerCredentials serverCredentials = TlsServerCredentials.newBuilder() + .keyManager(serverCert0File, serverKey0File) + .trustManager(caCertFile) + .clientAuth(ClientAuth.OPTIONAL) + .build(); + initServerWithStaticAuthz(policy, serverCredentials); + ChannelCredentials channelCredentials = TlsChannelCredentials.newBuilder() + .trustManager(caCertFile) + .build(); + getStub(channelCredentials).unaryRpc(SimpleRequest.getDefaultInstance()); + } + + private static class SimpleServiceImpl extends SimpleServiceGrpc.SimpleServiceImplBase { + @Override + public void unaryRpc(SimpleRequest req, StreamObserver respOb) { + respOb.onNext(SimpleResponse.getDefaultInstance()); + respOb.onCompleted(); + } + } +} diff --git a/authz/src/test/java/io/grpc/authz/AuthorizationServerInterceptorTest.java b/authz/src/test/java/io/grpc/authz/AuthorizationServerInterceptorTest.java new file mode 100644 index 0000000000..990228e2c9 --- /dev/null +++ b/authz/src/test/java/io/grpc/authz/AuthorizationServerInterceptorTest.java @@ -0,0 +1,68 @@ +/* + * Copyright 2022 The gRPC Authors + * + * Licensed 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 + * + * http://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 io.grpc.authz; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.fail; + +import java.io.IOException; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + + +@RunWith(JUnit4.class) +public class AuthorizationServerInterceptorTest { + @Test + public void invalidPolicyFailsStaticAuthzInterceptorCreation() throws Exception { + String policy = "{ \"name\": \"abc\",, }"; + try { + AuthorizationServerInterceptor.create(policy); + fail("exception expected"); + } catch (IOException ioe) { + assertThat(ioe).hasMessageThat().isEqualTo( + "Use JsonReader.setLenient(true) to accept malformed JSON" + + " at line 1 column 18 path $.name"); + } catch (Exception e) { + throw new AssertionError("the test failed ", e); + } + } + + @Test + public void validPolicyCreatesStaticAuthzInterceptor() throws Exception { + String policy = "{" + + " \"name\" : \"authz\"," + + " \"deny_rules\": [" + + " {" + + " \"name\": \"deny_foo\"," + + " \"source\": {" + + " \"principals\": [" + + " \"spiffe://foo.com\"" + + " ]" + + " }" + + " }" + + " ]," + + " \"allow_rules\": [" + + " {" + + " \"name\": \"allow_all\"" + + " }" + + " ]" + + "}"; + assertNotNull(AuthorizationServerInterceptor.create(policy)); + } +} diff --git a/xds/src/main/java/io/grpc/xds/InternalRbacFilter.java b/xds/src/main/java/io/grpc/xds/InternalRbacFilter.java new file mode 100644 index 0000000000..54e6c748cd --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/InternalRbacFilter.java @@ -0,0 +1,40 @@ +/* + * Copyright 2021 The gRPC Authors + * + * Licensed 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 + * + * http://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 io.grpc.xds; + +import io.envoyproxy.envoy.extensions.filters.http.rbac.v3.RBAC; +import io.grpc.Internal; +import io.grpc.ServerInterceptor; +import io.grpc.xds.RbacConfig; +import io.grpc.xds.RbacFilter; + +/** This class exposes some functionality in RbacFilter to other packages. */ +@Internal +public final class InternalRbacFilter { + + private InternalRbacFilter() {} + + /** Parses RBAC filter config and creates AuthorizationServerInterceptor. */ + public static ServerInterceptor createInterceptor(RBAC rbac) { + ConfigOrError filterConfig = RbacFilter.parseRbacConfig(rbac); + if (filterConfig.errorDetail != null) { + throw new IllegalArgumentException( + String.format("Failed to parse Rbac policy: %s", filterConfig.errorDetail)); + } + return new RbacFilter().buildServerInterceptor(filterConfig.config, null); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/MatcherParser.java b/xds/src/main/java/io/grpc/xds/internal/MatcherParser.java index 0a971655df..39b80bbcc0 100644 --- a/xds/src/main/java/io/grpc/xds/internal/MatcherParser.java +++ b/xds/src/main/java/io/grpc/xds/internal/MatcherParser.java @@ -54,6 +54,12 @@ public final class MatcherParser { case SUFFIX_MATCH: return Matchers.HeaderMatcher.forSuffix( proto.getName(), proto.getSuffixMatch(), proto.getInvertMatch()); + case CONTAINS_MATCH: + return Matchers.HeaderMatcher.forContains( + proto.getName(), proto.getContainsMatch(), proto.getInvertMatch()); + case STRING_MATCH: + return Matchers.HeaderMatcher.forString( + proto.getName(), parseStringMatcher(proto.getStringMatch()), proto.getInvertMatch()); case HEADERMATCHSPECIFIER_NOT_SET: default: throw new IllegalArgumentException( diff --git a/xds/src/main/java/io/grpc/xds/internal/Matchers.java b/xds/src/main/java/io/grpc/xds/internal/Matchers.java index 3bf7b7723e..f833fd2e48 100644 --- a/xds/src/main/java/io/grpc/xds/internal/Matchers.java +++ b/xds/src/main/java/io/grpc/xds/internal/Matchers.java @@ -62,6 +62,14 @@ public final class Matchers { @Nullable public abstract String suffix(); + // Matches header value with the substring. + @Nullable + public abstract String contains(); + + // Matches header value with the string matcher. + @Nullable + public abstract StringMatcher stringMatcher(); + // Whether the matching semantics is inverted. E.g., present && !inverted -> !present public abstract boolean inverted(); @@ -69,50 +77,71 @@ public final class Matchers { public static HeaderMatcher forExactValue(String name, String exactValue, boolean inverted) { checkNotNull(name, "name"); checkNotNull(exactValue, "exactValue"); - return HeaderMatcher.create(name, exactValue, null, null, null, null, null, inverted); + return HeaderMatcher.create( + name, exactValue, null, null, null, null, null, null, null, inverted); } /** The request header value should match the regular expression pattern. */ public static HeaderMatcher forSafeRegEx(String name, Pattern safeRegEx, boolean inverted) { checkNotNull(name, "name"); checkNotNull(safeRegEx, "safeRegEx"); - return HeaderMatcher.create(name, null, safeRegEx, null, null, null, null, inverted); + return HeaderMatcher.create( + name, null, safeRegEx, null, null, null, null, null, null, inverted); } /** The request header value should be within the range. */ public static HeaderMatcher forRange(String name, Range range, boolean inverted) { checkNotNull(name, "name"); checkNotNull(range, "range"); - return HeaderMatcher.create(name, null, null, range, null, null, null, inverted); + return HeaderMatcher.create(name, null, null, range, null, null, null, null, null, inverted); } /** The request header value should exist. */ public static HeaderMatcher forPresent(String name, boolean present, boolean inverted) { checkNotNull(name, "name"); - return HeaderMatcher.create(name, null, null, null, present, null, null, inverted); + return HeaderMatcher.create( + name, null, null, null, present, null, null, null, null, inverted); } /** The request header value should have this prefix. */ public static HeaderMatcher forPrefix(String name, String prefix, boolean inverted) { checkNotNull(name, "name"); checkNotNull(prefix, "prefix"); - return HeaderMatcher.create(name, null, null, null, null, prefix, null, inverted); + return HeaderMatcher.create(name, null, null, null, null, prefix, null, null, null, inverted); } /** The request header value should have this suffix. */ public static HeaderMatcher forSuffix(String name, String suffix, boolean inverted) { checkNotNull(name, "name"); checkNotNull(suffix, "suffix"); - return HeaderMatcher.create(name, null, null, null, null, null, suffix, inverted); + return HeaderMatcher.create(name, null, null, null, null, null, suffix, null, null, inverted); + } + + /** The request header value should have this substring. */ + public static HeaderMatcher forContains(String name, String contains, boolean inverted) { + checkNotNull(name, "name"); + checkNotNull(contains, "contains"); + return HeaderMatcher.create( + name, null, null, null, null, null, null, contains, null, inverted); + } + + /** The request header value should match this stringMatcher. */ + public static HeaderMatcher forString( + String name, StringMatcher stringMatcher, boolean inverted) { + checkNotNull(name, "name"); + checkNotNull(stringMatcher, "stringMatcher"); + return HeaderMatcher.create( + name, null, null, null, null, null, null, null, stringMatcher, inverted); } private static HeaderMatcher create(String name, @Nullable String exactValue, @Nullable Pattern safeRegEx, @Nullable Range range, @Nullable Boolean present, @Nullable String prefix, - @Nullable String suffix, boolean inverted) { + @Nullable String suffix, @Nullable String contains, + @Nullable StringMatcher stringMatcher, boolean inverted) { checkNotNull(name, "name"); return new AutoValue_Matchers_HeaderMatcher(name, exactValue, safeRegEx, range, present, - prefix, suffix, inverted); + prefix, suffix, contains, stringMatcher, inverted); } /** Returns the matching result. */ @@ -138,8 +167,12 @@ public final class Matchers { baseMatch = value.startsWith(prefix()); } else if (present() != null) { baseMatch = present(); - } else { + } else if (suffix() != null) { baseMatch = value.endsWith(suffix()); + } else if (contains() != null) { + baseMatch = value.contains(contains()); + } else { + baseMatch = stringMatcher().matches(value); } return baseMatch != inverted(); } diff --git a/xds/src/test/java/io/grpc/xds/internal/MatcherTest.java b/xds/src/test/java/io/grpc/xds/internal/MatcherTest.java index 93a9b7087d..4e5d278d50 100644 --- a/xds/src/test/java/io/grpc/xds/internal/MatcherTest.java +++ b/xds/src/test/java/io/grpc/xds/internal/MatcherTest.java @@ -163,6 +163,15 @@ public class MatcherTest { assertThat(matcher.matches("1v2")).isFalse(); assertThat(matcher.matches(null)).isFalse(); + matcher = HeaderMatcher.forContains("version", "v1", false); + assertThat(matcher.matches("xv1")).isTrue(); + assertThat(matcher.matches("1vx")).isFalse(); + assertThat(matcher.matches(null)).isFalse(); + matcher = HeaderMatcher.forContains("version", "v1", true); + assertThat(matcher.matches("xv1")).isFalse(); + assertThat(matcher.matches("1vx")).isTrue(); + assertThat(matcher.matches(null)).isFalse(); + matcher = HeaderMatcher.forSafeRegEx("version", Pattern.compile("v2.*"), false); assertThat(matcher.matches("v2..")).isTrue(); assertThat(matcher.matches("v1")).isFalse(); @@ -180,5 +189,14 @@ public class MatcherTest { assertThat(matcher.matches("1")).isTrue(); assertThat(matcher.matches("8080")).isFalse(); assertThat(matcher.matches(null)).isFalse(); + + matcher = HeaderMatcher.forString("version", StringMatcher.forExact("v1", true), false); + assertThat(matcher.matches("v1")).isTrue(); + assertThat(matcher.matches("v1x")).isFalse(); + assertThat(matcher.matches(null)).isFalse(); + matcher = HeaderMatcher.forString("version", StringMatcher.forExact("v1", true), true); + assertThat(matcher.matches("v1x")).isTrue(); + assertThat(matcher.matches("v1")).isFalse(); + assertThat(matcher.matches(null)).isFalse(); } }