diff --git a/xds/src/main/java/io/grpc/xds/ClientXdsClient.java b/xds/src/main/java/io/grpc/xds/ClientXdsClient.java index 0bb1e7aff4..c037d6c416 100644 --- a/xds/src/main/java/io/grpc/xds/ClientXdsClient.java +++ b/xds/src/main/java/io/grpc/xds/ClientXdsClient.java @@ -66,15 +66,15 @@ import io.grpc.xds.Filter.FilterConfig; import io.grpc.xds.Filter.NamedFilterConfig; import io.grpc.xds.LoadStatsManager2.ClusterDropStats; import io.grpc.xds.LoadStatsManager2.ClusterLocalityStats; -import io.grpc.xds.Matchers.FractionMatcher; -import io.grpc.xds.Matchers.HeaderMatcher; -import io.grpc.xds.Matchers.PathMatcher; import io.grpc.xds.VirtualHost.Route; import io.grpc.xds.VirtualHost.Route.RouteAction; import io.grpc.xds.VirtualHost.Route.RouteAction.ClusterWeight; import io.grpc.xds.VirtualHost.Route.RouteAction.HashPolicy; import io.grpc.xds.VirtualHost.Route.RouteMatch; +import io.grpc.xds.VirtualHost.Route.RouteMatch.PathMatcher; import io.grpc.xds.XdsLogger.XdsLogLevel; +import io.grpc.xds.internal.Matchers.FractionMatcher; +import io.grpc.xds.internal.Matchers.HeaderMatcher; import java.net.InetSocketAddress; import java.util.ArrayList; import java.util.Collection; diff --git a/xds/src/main/java/io/grpc/xds/Matchers.java b/xds/src/main/java/io/grpc/xds/Matchers.java deleted file mode 100644 index 8018dc5ffa..0000000000 --- a/xds/src/main/java/io/grpc/xds/Matchers.java +++ /dev/null @@ -1,171 +0,0 @@ -/* - * 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 static com.google.common.base.Preconditions.checkNotNull; - -import com.google.auto.value.AutoValue; -import com.google.re2j.Pattern; -import javax.annotation.Nullable; - -/** A group of request matchers. */ -final class Matchers { - private Matchers() {} - - /** Matcher for HTTP request path. */ - @AutoValue - abstract static class PathMatcher { - // Exact full path to be matched. - @Nullable - abstract String path(); - - // Path prefix to be matched. - @Nullable - abstract String prefix(); - - // Regular expression pattern of the path to be matched. - @Nullable - abstract Pattern regEx(); - - // Whether case sensitivity is taken into account for matching. - // Only valid for full path matching or prefix matching. - abstract boolean caseSensitive(); - - static PathMatcher fromPath(String path, boolean caseSensitive) { - checkNotNull(path, "path"); - return PathMatcher.create(path, null, null, caseSensitive); - } - - static PathMatcher fromPrefix(String prefix, boolean caseSensitive) { - checkNotNull(prefix, "prefix"); - return PathMatcher.create(null, prefix, null, caseSensitive); - } - - static PathMatcher fromRegEx(Pattern regEx) { - checkNotNull(regEx, "regEx"); - return PathMatcher.create(null, null, regEx, false /* doesn't matter */); - } - - private static PathMatcher create(@Nullable String path, @Nullable String prefix, - @Nullable Pattern regEx, boolean caseSensitive) { - return new AutoValue_Matchers_PathMatcher(path, prefix, regEx, caseSensitive); - } - } - - /** Matcher for HTTP request headers. */ - @AutoValue - abstract static class HeaderMatcher { - // Name of the header to be matched. - abstract String name(); - - // Matches exact header value. - @Nullable - abstract String exactValue(); - - // Matches header value with the regular expression pattern. - @Nullable - abstract Pattern safeRegEx(); - - // Matches header value an integer value in the range. - @Nullable - abstract Range range(); - - // Matches header presence. - @Nullable - abstract Boolean present(); - - // Matches header value with the prefix. - @Nullable - abstract String prefix(); - - // Matches header value with the suffix. - @Nullable - abstract String suffix(); - - // Whether the matching semantics is inverted. E.g., present && !inverted -> !present - abstract boolean inverted(); - - 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); - } - - 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); - } - - 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); - } - - static HeaderMatcher forPresent(String name, boolean present, boolean inverted) { - checkNotNull(name, "name"); - return HeaderMatcher.create(name, null, null, null, present, null, null, inverted); - } - - 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); - } - - 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); - } - - 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) { - checkNotNull(name, "name"); - return new AutoValue_Matchers_HeaderMatcher(name, exactValue, safeRegEx, range, present, - prefix, suffix, inverted); - } - - /** Represents an integer range. */ - @AutoValue - abstract static class Range { - abstract long start(); - - abstract long end(); - - static Range create(long start, long end) { - return new AutoValue_Matchers_HeaderMatcher_Range(start, end); - } - - } - } - - /** Represents a fractional value. */ - @AutoValue - abstract static class FractionMatcher { - abstract int numerator(); - - abstract int denominator(); - - static FractionMatcher create(int numerator, int denominator) { - return new AutoValue_Matchers_FractionMatcher(numerator, denominator); - } - } -} diff --git a/xds/src/main/java/io/grpc/xds/VirtualHost.java b/xds/src/main/java/io/grpc/xds/VirtualHost.java index 708b6a922d..59ca702d67 100644 --- a/xds/src/main/java/io/grpc/xds/VirtualHost.java +++ b/xds/src/main/java/io/grpc/xds/VirtualHost.java @@ -25,9 +25,8 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.re2j.Pattern; import io.grpc.xds.Filter.FilterConfig; -import io.grpc.xds.Matchers.FractionMatcher; -import io.grpc.xds.Matchers.HeaderMatcher; -import io.grpc.xds.Matchers.PathMatcher; +import io.grpc.xds.internal.Matchers.FractionMatcher; +import io.grpc.xds.internal.Matchers.HeaderMatcher; import java.util.Collections; import java.util.List; import java.util.Map; @@ -90,6 +89,47 @@ abstract class VirtualHost { return new AutoValue_VirtualHost_Route_RouteMatch(pathMatcher, ImmutableList.copyOf(headerMatchers), fractionMatcher); } + + /** Matcher for HTTP request path. */ + @AutoValue + abstract static class PathMatcher { + // Exact full path to be matched. + @Nullable + abstract String path(); + + // Path prefix to be matched. + @Nullable + abstract String prefix(); + + // Regular expression pattern of the path to be matched. + @Nullable + abstract Pattern regEx(); + + // Whether case sensitivity is taken into account for matching. + // Only valid for full path matching or prefix matching. + abstract boolean caseSensitive(); + + static PathMatcher fromPath(String path, boolean caseSensitive) { + checkNotNull(path, "path"); + return create(path, null, null, caseSensitive); + } + + static PathMatcher fromPrefix(String prefix, boolean caseSensitive) { + checkNotNull(prefix, "prefix"); + return create(null, prefix, null, caseSensitive); + } + + static PathMatcher fromRegEx(Pattern regEx) { + checkNotNull(regEx, "regEx"); + return create(null, null, regEx, false /* doesn't matter */); + } + + private static PathMatcher create(@Nullable String path, @Nullable String prefix, + @Nullable Pattern regEx, boolean caseSensitive) { + return new AutoValue_VirtualHost_Route_RouteMatch_PathMatcher(path, prefix, regEx, + caseSensitive); + } + } } @AutoValue diff --git a/xds/src/main/java/io/grpc/xds/XdsClientWrapperForServerSds.java b/xds/src/main/java/io/grpc/xds/XdsClientWrapperForServerSds.java index 08c1f2cf17..2cfbb325ad 100644 --- a/xds/src/main/java/io/grpc/xds/XdsClientWrapperForServerSds.java +++ b/xds/src/main/java/io/grpc/xds/XdsClientWrapperForServerSds.java @@ -30,12 +30,12 @@ import io.grpc.internal.SharedResourceHolder; import io.grpc.xds.EnvoyServerProtoData.CidrRange; import io.grpc.xds.EnvoyServerProtoData.FilterChain; import io.grpc.xds.EnvoyServerProtoData.FilterChainMatch; +import io.grpc.xds.internal.Matchers.CidrMatcher; import io.grpc.xds.internal.sds.SslContextProviderSupplier; import io.netty.channel.Channel; import io.netty.channel.epoll.Epoll; import io.netty.channel.epoll.EpollEventLoopGroup; import io.netty.util.concurrent.DefaultThreadFactory; -import java.math.BigInteger; import java.net.Inet6Address; import java.net.InetAddress; import java.net.InetSocketAddress; @@ -338,20 +338,8 @@ public final class XdsClientWrapperForServerSds { return filtered; } - private static boolean isCidrMatching(byte[] cidrBytes, byte[] addressBytes, int prefixLen) { - BigInteger cidrInt = new BigInteger(cidrBytes); - BigInteger addrInt = new BigInteger(addressBytes); - - int shiftAmount = 8 * cidrBytes.length - prefixLen; - - cidrInt = cidrInt.shiftRight(shiftAmount); - addrInt = addrInt.shiftRight(shiftAmount); - return cidrInt.equals(addrInt); - } - private static int getMatchingPrefixLength( FilterChainMatch filterChainMatch, InetAddress address, boolean forDestination) { - byte[] addressBytes = address.getAddress(); boolean isIPv6 = address instanceof Inet6Address; List cidrRanges = forDestination @@ -366,10 +354,9 @@ public final class XdsClientWrapperForServerSds { InetAddress cidrAddr = cidrRange.getAddressPrefix(); boolean cidrIsIpv6 = cidrAddr instanceof Inet6Address; if (isIPv6 == cidrIsIpv6) { - byte[] cidrBytes = cidrAddr.getAddress(); int prefixLen = cidrRange.getPrefixLen(); - if (isCidrMatching(cidrBytes, addressBytes, prefixLen) - && prefixLen > matchingPrefixLength) { + CidrMatcher matcher = CidrMatcher.create(cidrAddr, prefixLen); + if (matcher.matches(address) && prefixLen > matchingPrefixLength) { matchingPrefixLength = prefixLen; } } diff --git a/xds/src/main/java/io/grpc/xds/XdsNameResolver.java b/xds/src/main/java/io/grpc/xds/XdsNameResolver.java index 05a89bb069..c88d71857b 100644 --- a/xds/src/main/java/io/grpc/xds/XdsNameResolver.java +++ b/xds/src/main/java/io/grpc/xds/XdsNameResolver.java @@ -46,15 +46,13 @@ import io.grpc.internal.ObjectPool; import io.grpc.xds.Filter.ClientInterceptorBuilder; import io.grpc.xds.Filter.FilterConfig; import io.grpc.xds.Filter.NamedFilterConfig; -import io.grpc.xds.Matchers.FractionMatcher; -import io.grpc.xds.Matchers.HeaderMatcher; -import io.grpc.xds.Matchers.PathMatcher; import io.grpc.xds.ThreadSafeRandom.ThreadSafeRandomImpl; import io.grpc.xds.VirtualHost.Route; import io.grpc.xds.VirtualHost.Route.RouteAction; import io.grpc.xds.VirtualHost.Route.RouteAction.ClusterWeight; import io.grpc.xds.VirtualHost.Route.RouteAction.HashPolicy; import io.grpc.xds.VirtualHost.Route.RouteMatch; +import io.grpc.xds.VirtualHost.Route.RouteMatch.PathMatcher; import io.grpc.xds.XdsClient.LdsResourceWatcher; import io.grpc.xds.XdsClient.LdsUpdate; import io.grpc.xds.XdsClient.RdsResourceWatcher; @@ -62,6 +60,8 @@ import io.grpc.xds.XdsClient.RdsUpdate; import io.grpc.xds.XdsLogger.XdsLogLevel; import io.grpc.xds.XdsNameResolverProvider.CallCounterProvider; import io.grpc.xds.XdsNameResolverProvider.XdsClientPoolFactory; +import io.grpc.xds.internal.Matchers.FractionMatcher; +import io.grpc.xds.internal.Matchers.HeaderMatcher; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -577,6 +577,7 @@ final class XdsNameResolver extends NameResolver { return pathMatcher.regEx().matches(fullMethodName); } + // TODO(zivy): consider reuse Matchers.HeaderMatcher.matches() private static boolean matchHeader(HeaderMatcher headerMatcher, @Nullable String value) { if (headerMatcher.present() != null) { return (value == null) == headerMatcher.present().equals(headerMatcher.inverted()); diff --git a/xds/src/main/java/io/grpc/xds/internal/Matchers.java b/xds/src/main/java/io/grpc/xds/internal/Matchers.java new file mode 100644 index 0000000000..315f28d72b --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/Matchers.java @@ -0,0 +1,299 @@ +/* + * 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.internal; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.auto.value.AutoValue; +import com.google.re2j.Pattern; +import java.math.BigInteger; +import java.net.InetAddress; +import javax.annotation.Nullable; + +/** + * Provides a group of request matchers. A matcher evaluates an input and tells whether certain + * argument in the input matches a predefined matching pattern. + */ +public final class Matchers { + + /** Matcher for HTTP request headers. */ + @AutoValue + public abstract static class HeaderMatcher { + // Name of the header to be matched. + public abstract String name(); + + // Matches exact header value. + @Nullable + public abstract String exactValue(); + + // Matches header value with the regular expression pattern. + @Nullable + public abstract Pattern safeRegEx(); + + // Matches header value an integer value in the range. + @Nullable + public abstract Range range(); + + // Matches header presence. + @Nullable + public abstract Boolean present(); + + // Matches header value with the prefix. + @Nullable + public abstract String prefix(); + + // Matches header value with the suffix. + @Nullable + public abstract String suffix(); + + // Whether the matching semantics is inverted. E.g., present && !inverted -> !present + public abstract boolean inverted(); + + /** The request header value should exactly match the specified value. */ + 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); + } + + /** 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); + } + + /** 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); + } + + /** 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); + } + + /** 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); + } + + /** 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); + } + + 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) { + checkNotNull(name, "name"); + return new AutoValue_Matchers_HeaderMatcher(name, exactValue, safeRegEx, range, present, + prefix, suffix, inverted); + } + + /** Returns the matching result. */ + public boolean matches(@Nullable String value) { + if (present() != null) { + return (value == null) == present().equals(inverted()); + } + // FIXME(zivy@): invert result for null value. + // https://github.com/envoyproxy/envoy/blob/0fae6970ddaf93f024908ba304bbd2b34e997a51/source/common/http/header_utility.cc#L130 + if (value == null) { + return false; + } + boolean baseMatch; + if (exactValue() != null) { + baseMatch = exactValue().equals(value); + } else if (safeRegEx() != null) { + baseMatch = safeRegEx().matches(value); + } else if (range() != null) { + long numValue; + try { + numValue = Long.parseLong(value); + baseMatch = numValue >= range().start() + && numValue <= range().end(); + } catch (NumberFormatException ignored) { + baseMatch = false; + } + } else if (prefix() != null) { + baseMatch = value.startsWith(prefix()); + } else { + baseMatch = value.endsWith(suffix()); + } + return baseMatch != inverted(); + } + + /** Represents an integer range. */ + @AutoValue + public abstract static class Range { + public abstract long start(); + + public abstract long end(); + + public static Range create(long start, long end) { + return new AutoValue_Matchers_HeaderMatcher_Range(start, end); + } + } + } + + /** Represents a fractional value. */ + @AutoValue + public abstract static class FractionMatcher { + public abstract int numerator(); + + public abstract int denominator(); + + public static FractionMatcher create(int numerator, int denominator) { + return new AutoValue_Matchers_FractionMatcher(numerator, denominator); + } + } + + /** Represents various ways to match a string .*/ + @AutoValue + public abstract static class StringMatcher { + @Nullable + abstract String exact(); + + // The input string has this prefix. + @Nullable + abstract String prefix(); + + // The input string has this suffix. + @Nullable + abstract String suffix(); + + // The input string matches the regular expression. + @Nullable + abstract Pattern regEx(); + + // The input string has this substring. + @Nullable + abstract String contains(); + + // If true, exact/prefix/suffix matching should be case insensitive. + abstract boolean ignoreCase(); + + /** The input string should exactly matches the specified string. */ + public static StringMatcher forExact(String exact, boolean ignoreCase) { + checkNotNull(exact, "exact"); + return StringMatcher.create(exact, null, null, null, null, + ignoreCase); + } + + /** The input string should have the prefix. */ + public static StringMatcher forPrefix(String prefix, boolean ignoreCase) { + checkNotNull(prefix, "prefix"); + return StringMatcher.create(null, prefix, null, null, null, + ignoreCase); + } + + /** The input string should have the suffix. */ + public static StringMatcher forSuffix(String suffix, boolean ignoreCase) { + checkNotNull(suffix, "suffix"); + return StringMatcher.create(null, null, suffix, null, null, + ignoreCase); + } + + /** The input string should match this pattern. */ + public static StringMatcher forSafeRegEx(Pattern regEx) { + checkNotNull(regEx, "regEx"); + return StringMatcher.create(null, null, null, regEx, null, + false/* doesn't matter */); + } + + /** The input string should contain this substring. */ + public static StringMatcher forContains(String contains) { + checkNotNull(contains, "contains"); + return StringMatcher.create(null, null, null, null, contains, + false/* doesn't matter */); + } + + /** Returns the matching result for this string. */ + public boolean matches(String args) { + if (args == null) { + return false; + } + if (exact() != null) { + return ignoreCase() + ? exact().equalsIgnoreCase(args) + : exact().equals(args); + } else if (prefix() != null) { + return ignoreCase() + ? args.toLowerCase().startsWith(prefix().toLowerCase()) + : args.startsWith(prefix()); + } else if (suffix() != null) { + return ignoreCase() + ? args.toLowerCase().endsWith(suffix().toLowerCase()) + : args.endsWith(suffix()); + } else if (contains() != null) { + return args.contains(contains()); + } + return regEx().matches(args); + } + + private static StringMatcher create(@Nullable String exact, @Nullable String prefix, + @Nullable String suffix, @Nullable Pattern regEx, @Nullable String contains, + boolean ignoreCase) { + return new AutoValue_Matchers_StringMatcher(exact, prefix, suffix, regEx, contains, + ignoreCase); + } + } + + /** Matcher to evaluate whether an IPv4 or IPv6 address is within a CIDR range. */ + @AutoValue + public abstract static class CidrMatcher { + + abstract InetAddress addressPrefix(); + + abstract int prefixLen(); + + /** Returns matching result for this address. */ + public boolean matches(InetAddress address) { + if (address == null) { + return false; + } + byte[] cidr = addressPrefix().getAddress(); + byte[] addr = address.getAddress(); + if (addr.length != cidr.length) { + return false; + } + BigInteger cidrInt = new BigInteger(cidr); + BigInteger addrInt = new BigInteger(addr); + + int shiftAmount = 8 * cidr.length - prefixLen(); + + cidrInt = cidrInt.shiftRight(shiftAmount); + addrInt = addrInt.shiftRight(shiftAmount); + return cidrInt.equals(addrInt); + } + + /** Constructs a CidrMatcher with this prefix and prefix length. + * Do not provide string addressPrefix constructor to avoid IO exception handling. + * */ + public static CidrMatcher create(InetAddress addressPrefix, int prefixLen) { + return new AutoValue_Matchers_CidrMatcher(addressPrefix, prefixLen); + } + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/rbac/engine/GrpcAuthorizationEngine.java b/xds/src/main/java/io/grpc/xds/internal/rbac/engine/GrpcAuthorizationEngine.java new file mode 100644 index 0000000000..32edd0f0f7 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/rbac/engine/GrpcAuthorizationEngine.java @@ -0,0 +1,415 @@ +/* + * 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.internal.rbac.engine; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.auto.value.AutoValue; +import com.google.common.base.Joiner; +import io.grpc.Grpc; +import io.grpc.Metadata; +import io.grpc.ServerCall; +import io.grpc.xds.internal.Matchers; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.security.cert.Certificate; +import java.security.cert.CertificateParsingException; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.annotation.Nullable; +import javax.net.ssl.SSLPeerUnverifiedException; +import javax.net.ssl.SSLSession; + +/** + * Implementation of gRPC server access control based on envoy RBAC protocol: + * https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/rbac/v3/rbac.proto + * + *

One GrpcAuthorizationEngine is initialized with one action type and a list of policies. + * Policies are examined sequentially in order in an any match fashion, and the first matched policy + * will be returned. If not matched at all, the opposite action type is returned as a result. + */ +public final class GrpcAuthorizationEngine { + private static final Logger log = Logger.getLogger(GrpcAuthorizationEngine.class.getName()); + + private final AuthConfig authConfig; + + /** Instantiated with envoy policyMatcher configuration. */ + public GrpcAuthorizationEngine(AuthConfig authConfig) { + this.authConfig = authConfig; + } + + /** Return the auth decision for the request argument against the policies. */ + public AuthDecision evaluate(Metadata metadata, ServerCall serverCall) { + checkNotNull(metadata, "metadata"); + checkNotNull(serverCall, "serverCall"); + String firstMatch = null; + EvaluateArgs args = new EvaluateArgs(metadata, serverCall); + for (PolicyMatcher policyMatcher : authConfig.policies) { + if (policyMatcher.matches(args)) { + firstMatch = policyMatcher.name; + break; + } + } + Action decisionType = Action.DENY; + if (Action.DENY.equals(authConfig.action) == (firstMatch == null)) { + decisionType = Action.ALLOW; + } + log.log(Level.FINER, "RBAC decision: {0}, policy match: {1}.", + new Object[]{decisionType, firstMatch}); + return AuthDecision.create(decisionType, firstMatch); + } + + public enum Action { + ALLOW, + DENY, + } + + /** + * An authorization decision provides information about the decision type and the policy name + * identifier based on the authorization engine evaluation. */ + @AutoValue + public abstract static class AuthDecision { + public abstract Action decision(); + + @Nullable + public abstract String matchingPolicyName(); + + static AuthDecision create(Action decisionType, @Nullable String matchingPolicy) { + return new AutoValue_GrpcAuthorizationEngine_AuthDecision(decisionType, matchingPolicy); + } + } + + /** Represents authorization config policy that the engine will evaluate against. */ + public static final class AuthConfig { + private final List policies; + private final Action action; + + public AuthConfig(List policies, Action action) { + this.policies = Collections.unmodifiableList(new ArrayList<>(policies)); + this.action = action; + } + } + + /** + * Implements a top level {@link Matcher} for a single RBAC policy configuration per envoy + * protocol: + * https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/rbac/v3/rbac.proto#config-rbac-v3-policy. + * + *

Currently we only support matching some of the request fields. Those unsupported fields are + * considered not match until we stop ignoring them. + */ + public static final class PolicyMatcher implements Matcher { + private final OrMatcher permissions; + private final OrMatcher principals; + private final String name; + + /** Constructs a matcher for one RBAC policy. */ + public PolicyMatcher(String name, OrMatcher permissions, OrMatcher principals) { + this.name = name; + this.permissions = permissions; + this.principals = principals; + } + + @Override + public boolean matches(EvaluateArgs args) { + return permissions.matches(args) && principals.matches(args); + } + } + + public static final class AuthenticatedMatcher implements Matcher { + private final Matchers.StringMatcher delegate; + + /** + * Passing in null will match all authenticated user, i.e. SSL session is present. + * https://github.com/envoyproxy/envoy/blob/main/api/envoy/config/rbac/v3/rbac.proto#L240 + * */ + public AuthenticatedMatcher(@Nullable Matchers.StringMatcher delegate) { + this.delegate = delegate; + } + + @Override + public boolean matches(EvaluateArgs args) { + Collection principalNames = args.getPrincipalNames(); + log.log(Level.FINER, "Matching principal names: {0}", new Object[]{principalNames}); + // Null means unauthenticated connection. + if (principalNames == null) { + return false; + } + // Connection is authenticated, so returns match when delegated string matcher is not present. + if (delegate == null) { + return true; + } + for (String name : principalNames) { + if (delegate.matches(name)) { + return true; + } + } + return false; + } + } + + public static final class DestinationIpMatcher implements Matcher { + private final Matchers.CidrMatcher delegate; + + public DestinationIpMatcher(Matchers.CidrMatcher delegate) { + this.delegate = checkNotNull(delegate, "delegate"); + } + + @Override + public boolean matches(EvaluateArgs args) { + return delegate.matches(args.getDestinationIp()); + } + } + + public static final class SourceIpMatcher implements Matcher { + private final Matchers.CidrMatcher delegate; + + public SourceIpMatcher(Matchers.CidrMatcher delegate) { + this.delegate = checkNotNull(delegate, "delegate"); + } + + @Override + public boolean matches(EvaluateArgs args) { + return delegate.matches(args.getSourceIp()); + } + } + + public static final class PathMatcher implements Matcher { + private final Matchers.StringMatcher delegate; + + public PathMatcher(Matchers.StringMatcher delegate) { + this.delegate = checkNotNull(delegate, "delegate"); + } + + @Override + public boolean matches(EvaluateArgs args) { + return delegate.matches(args.getPath()); + } + } + + public static final class HeaderMatcher implements Matcher { + private final Matchers.HeaderMatcher delegate; + + public HeaderMatcher(Matchers.HeaderMatcher delegate) { + this.delegate = checkNotNull(delegate, "delegate"); + } + + @Override + public boolean matches(EvaluateArgs args) { + return delegate.matches(args.getHeader(delegate.name())); + } + } + + public static final class DestinationPortMatcher implements Matcher { + private final int port; + + public DestinationPortMatcher(int port) { + this.port = port; + } + + @Override + public boolean matches(EvaluateArgs args) { + return port == args.getDestinationPort(); + } + } + + private static final class EvaluateArgs { + private final Metadata metadata; + private final ServerCall serverCall; + // https://github.com/envoyproxy/envoy/blob/63619d578e1abe0c1725ea28ba02f361466662e1/api/envoy/config/rbac/v3/rbac.proto#L238-L240 + private static final int URI_SAN = 6; + private static final int DNS_SAN = 2; + + private EvaluateArgs(Metadata metadata, ServerCall serverCall) { + this.metadata = metadata; + this.serverCall = serverCall; + } + + private String getPath() { + return "/" + serverCall.getMethodDescriptor().getFullMethodName(); + } + + /** + * Returns null for unauthenticated connection. + * Returns empty string collection if no valid certificate and no + * principal names we are interested in. + * https://github.com/envoyproxy/envoy/blob/0fae6970ddaf93f024908ba304bbd2b34e997a51/envoy/ssl/connection.h#L70 + */ + private Collection getPrincipalNames() { + SSLSession sslSession = serverCall.getAttributes().get(Grpc.TRANSPORT_ATTR_SSL_SESSION); + if (sslSession == null) { + return null; + } + try { + Certificate[] certs = sslSession.getPeerCertificates(); + if (certs == null || certs.length < 1) { + return Collections.singleton(""); + } + X509Certificate cert = (X509Certificate)certs[0]; + if (cert == null) { + return Collections.singleton(""); + } + Collection> names = cert.getSubjectAlternativeNames(); + List principalNames = new ArrayList<>(); + if (names != null) { + for (List name : names) { + if (URI_SAN == (Integer) name.get(0)) { + principalNames.add((String) name.get(1)); + } + } + if (!principalNames.isEmpty()) { + return Collections.unmodifiableCollection(principalNames); + } + for (List name : names) { + if (DNS_SAN == (Integer) name.get(0)) { + principalNames.add((String) name.get(1)); + } + } + if (!principalNames.isEmpty()) { + return Collections.unmodifiableCollection(principalNames); + } + } + if (cert.getSubjectDN() == null || cert.getSubjectDN().getName() == null) { + return Collections.singleton(""); + } + return Collections.singleton(cert.getSubjectDN().getName()); + } catch (SSLPeerUnverifiedException | CertificateParsingException ex) { + log.log(Level.FINE, "Unexpected getPrincipalNames error.", ex); + return Collections.singleton(""); + } + } + + @Nullable + private String getHeader(String headerName) { + if (headerName.endsWith(Metadata.BINARY_HEADER_SUFFIX)) { + return null; + } + Metadata.Key key; + try { + key = Metadata.Key.of(headerName, Metadata.ASCII_STRING_MARSHALLER); + } catch (IllegalArgumentException e) { + return null; + } + Iterable values = metadata.getAll(key); + return values == null ? null : Joiner.on(",").join(values); + } + + private InetAddress getDestinationIp() { + SocketAddress addr = serverCall.getAttributes().get(Grpc.TRANSPORT_ATTR_LOCAL_ADDR); + return addr == null ? null : ((InetSocketAddress) addr).getAddress(); + } + + private InetAddress getSourceIp() { + SocketAddress addr = serverCall.getAttributes().get(Grpc.TRANSPORT_ATTR_REMOTE_ADDR); + return addr == null ? null : ((InetSocketAddress) addr).getAddress(); + } + + private int getDestinationPort() { + SocketAddress addr = serverCall.getAttributes().get(Grpc.TRANSPORT_ATTR_LOCAL_ADDR); + return addr == null ? -1 : ((InetSocketAddress) addr).getPort(); + } + } + + interface Matcher { + boolean matches(EvaluateArgs args); + } + + public static final class OrMatcher implements Matcher { + private final List anyMatch; + + /** Matches when any of the matcher matches. */ + public OrMatcher(List matchers) { + checkNotNull(matchers, "matchers"); + for (Matcher matcher : matchers) { + checkNotNull(matcher, "matcher"); + } + this.anyMatch = Collections.unmodifiableList(new ArrayList<>(matchers)); + } + + public static OrMatcher create(Matcher...matchers) { + return new OrMatcher(Arrays.asList(matchers)); + } + + @Override + public boolean matches(EvaluateArgs args) { + for (Matcher m : anyMatch) { + if (m.matches(args)) { + return true; + } + } + return false; + } + } + + public static final class AndMatcher implements Matcher { + private final List allMatch; + + /** Matches when all of the matchers match. */ + public AndMatcher(List matchers) { + checkNotNull(matchers, "matchers"); + for (Matcher matcher : matchers) { + checkNotNull(matcher, "matcher"); + } + this.allMatch = Collections.unmodifiableList(new ArrayList<>(matchers)); + } + + public static AndMatcher create(Matcher...matchers) { + return new AndMatcher(Arrays.asList(matchers)); + } + + @Override + public boolean matches(EvaluateArgs args) { + for (Matcher m : allMatch) { + if (!m.matches(args)) { + return false; + } + } + return true; + } + } + + /** Always true matcher.*/ + public static final class AlwaysTrueMatcher implements Matcher { + static AlwaysTrueMatcher INSTANCE = new AlwaysTrueMatcher(); + + @Override + public boolean matches(EvaluateArgs args) { + return true; + } + } + + /** Negate matcher.*/ + public static final class InvertMatcher implements Matcher { + private final Matcher toInvertMatcher; + + public InvertMatcher(Matcher matcher) { + this.toInvertMatcher = checkNotNull(matcher, "matcher"); + } + + @Override + public boolean matches(EvaluateArgs args) { + return !toInvertMatcher.matches(args); + } + } +} diff --git a/xds/src/test/java/io/grpc/xds/ClientXdsClientDataTest.java b/xds/src/test/java/io/grpc/xds/ClientXdsClientDataTest.java index 898bc45073..913e1693f7 100644 --- a/xds/src/test/java/io/grpc/xds/ClientXdsClientDataTest.java +++ b/xds/src/test/java/io/grpc/xds/ClientXdsClientDataTest.java @@ -73,15 +73,15 @@ import io.grpc.xds.Endpoints.LbEndpoint; import io.grpc.xds.Endpoints.LocalityLbEndpoints; import io.grpc.xds.FaultConfig.FaultAbort; import io.grpc.xds.Filter.FilterConfig; -import io.grpc.xds.Matchers.FractionMatcher; -import io.grpc.xds.Matchers.HeaderMatcher; -import io.grpc.xds.Matchers.PathMatcher; import io.grpc.xds.VirtualHost.Route; import io.grpc.xds.VirtualHost.Route.RouteAction; import io.grpc.xds.VirtualHost.Route.RouteAction.ClusterWeight; import io.grpc.xds.VirtualHost.Route.RouteAction.HashPolicy; import io.grpc.xds.VirtualHost.Route.RouteMatch; +import io.grpc.xds.VirtualHost.Route.RouteMatch.PathMatcher; import io.grpc.xds.XdsClient.CdsUpdate; +import io.grpc.xds.internal.Matchers.FractionMatcher; +import io.grpc.xds.internal.Matchers.HeaderMatcher; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; diff --git a/xds/src/test/java/io/grpc/xds/XdsNameResolverTest.java b/xds/src/test/java/io/grpc/xds/XdsNameResolverTest.java index f5c9acba92..75f81da558 100644 --- a/xds/src/test/java/io/grpc/xds/XdsNameResolverTest.java +++ b/xds/src/test/java/io/grpc/xds/XdsNameResolverTest.java @@ -67,14 +67,14 @@ import io.grpc.xds.FaultConfig.FaultAbort; import io.grpc.xds.FaultConfig.FaultDelay; import io.grpc.xds.Filter.FilterConfig; import io.grpc.xds.Filter.NamedFilterConfig; -import io.grpc.xds.Matchers.HeaderMatcher; -import io.grpc.xds.Matchers.PathMatcher; import io.grpc.xds.VirtualHost.Route; import io.grpc.xds.VirtualHost.Route.RouteAction; import io.grpc.xds.VirtualHost.Route.RouteAction.ClusterWeight; import io.grpc.xds.VirtualHost.Route.RouteAction.HashPolicy; import io.grpc.xds.VirtualHost.Route.RouteMatch; +import io.grpc.xds.VirtualHost.Route.RouteMatch.PathMatcher; import io.grpc.xds.XdsNameResolverProvider.XdsClientPoolFactory; +import io.grpc.xds.internal.Matchers.HeaderMatcher; import java.io.IOException; import java.util.Arrays; import java.util.Collections; diff --git a/xds/src/test/java/io/grpc/xds/internal/MatcherTest.java b/xds/src/test/java/io/grpc/xds/internal/MatcherTest.java new file mode 100644 index 0000000000..212005bdf8 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/MatcherTest.java @@ -0,0 +1,176 @@ +/* + * 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.internal; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.re2j.Pattern; +import io.grpc.xds.internal.Matchers.CidrMatcher; +import io.grpc.xds.internal.Matchers.HeaderMatcher; +import io.grpc.xds.internal.Matchers.HeaderMatcher.Range; +import io.grpc.xds.internal.Matchers.StringMatcher; +import java.net.InetAddress; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +@RunWith(JUnit4.class) +public class MatcherTest { + @Rule + public final MockitoRule mocks = MockitoJUnit.rule(); + + @Test + public void ipMatcher_Ipv4() throws Exception { + CidrMatcher matcher = CidrMatcher.create(InetAddress.getByName("10.10.24.10"), 20); + assertThat(matcher.matches(InetAddress.getByName("::0"))).isFalse(); + assertThat(matcher.matches(InetAddress.getByName("10.10.20.0"))).isTrue(); + assertThat(matcher.matches(InetAddress.getByName("10.10.16.0"))).isTrue(); + assertThat(matcher.matches(InetAddress.getByName("10.10.24.10"))).isTrue(); + assertThat(matcher.matches(InetAddress.getByName("10.10.31.0"))).isTrue(); + assertThat(matcher.matches(InetAddress.getByName("10.10.17.0"))).isTrue(); + assertThat(matcher.matches(InetAddress.getByName("10.32.20.0"))).isFalse(); + assertThat(matcher.matches(InetAddress.getByName("10.10.40.0"))).isFalse(); + matcher = CidrMatcher.create(InetAddress.getByName("0.0.0.0"), 20); + assertThat(matcher.matches(InetAddress.getByName("10.32.20.0"))).isFalse(); + assertThat(matcher.matches(InetAddress.getByName("0.0.31.0"))).isFalse(); + assertThat(matcher.matches(InetAddress.getByName("0.0.15.0"))).isTrue(); + assertThat(matcher.matches(null)).isFalse(); + } + + @Test + public void ipMatcher_Ipv6() throws Exception { + CidrMatcher matcher = CidrMatcher.create(InetAddress.getByName("2012:00fe:d808::"), 36); + assertThat(matcher.matches(InetAddress.getByName("0.0.0.0"))).isFalse(); + assertThat(matcher.matches(InetAddress.getByName("2012:00fe:d000::0"))).isTrue(); + assertThat(matcher.matches(InetAddress.getByName("2012:00fe:d808::"))).isTrue(); + assertThat(matcher.matches(InetAddress.getByName("2012:00fe:da81:0909:0008:4018:e930:b019"))) + .isTrue(); + assertThat(matcher.matches(InetAddress.getByName("2013:00fe:d000::0"))).isFalse(); + } + + @Test + public void stringMatcher() { + StringMatcher matcher = StringMatcher.forExact("essence", false); + assertThat(matcher.matches("elite")).isFalse(); + assertThat(matcher.matches("ess")).isFalse(); + assertThat(matcher.matches("")).isFalse(); + assertThat(matcher.matches("essential")).isFalse(); + assertThat(matcher.matches("Essence")).isFalse(); + assertThat(matcher.matches("essence")).isTrue(); + assertThat(matcher.matches((String)null)).isFalse(); + matcher = StringMatcher.forExact("essence", true); + assertThat(matcher.matches("Essence")).isTrue(); + assertThat(matcher.matches("essence")).isTrue(); + matcher = StringMatcher.forExact("", true); + assertThat(matcher.matches("essence")).isFalse(); + assertThat(matcher.matches("")).isTrue(); + + matcher = StringMatcher.forPrefix("Ess", false); + assertThat(matcher.matches("elite")).isFalse(); + assertThat(matcher.matches("ess")).isFalse(); + assertThat(matcher.matches("")).isFalse(); + assertThat(matcher.matches("e")).isFalse(); + assertThat(matcher.matches("essential")).isFalse(); + assertThat(matcher.matches("Essence")).isTrue(); + assertThat(matcher.matches("essence")).isFalse(); + assertThat(matcher.matches((String)null)).isFalse(); + matcher = StringMatcher.forPrefix("Ess", true); + assertThat(matcher.matches("esSEncE")).isTrue(); + assertThat(matcher.matches("ess")).isTrue(); + assertThat(matcher.matches("ES")).isFalse(); + matcher = StringMatcher.forPrefix("", false); + assertThat(matcher.matches("elite")).isTrue(); + + matcher = StringMatcher.forSuffix("ess", false); + assertThat(matcher.matches("elite")).isFalse(); + assertThat(matcher.matches("es")).isFalse(); + assertThat(matcher.matches("")).isFalse(); + assertThat(matcher.matches("ess")).isTrue(); + assertThat(matcher.matches("Excess")).isTrue(); + assertThat(matcher.matches("ExcesS")).isFalse(); + assertThat(matcher.matches((String)null)).isFalse(); + matcher = StringMatcher.forSuffix("ess", true); + assertThat(matcher.matches("esSEncESs")).isTrue(); + assertThat(matcher.matches("ess")).isTrue(); + matcher = StringMatcher.forSuffix("", true); + assertThat(matcher.matches("")).isTrue(); + assertThat(matcher.matches("any")).isTrue(); + + matcher = StringMatcher.forContains("ess"); + assertThat(matcher.matches("elite")).isFalse(); + assertThat(matcher.matches("es")).isFalse(); + assertThat(matcher.matches("")).isFalse(); + assertThat(matcher.matches("essence")).isTrue(); + assertThat(matcher.matches("eSs")).isFalse(); + assertThat(matcher.matches("ExcesS")).isFalse(); + assertThat(matcher.matches((String)null)).isFalse(); + + matcher = StringMatcher.forSafeRegEx(Pattern.compile("^es*.*")); + assertThat(matcher.matches("essence")).isTrue(); + assertThat(matcher.matches("")).isFalse(); + } + + @Test + public void headerMatcher() { + HeaderMatcher matcher = HeaderMatcher.forExactValue("version", "v1", false); + assertThat(matcher.matches("v1")).isTrue(); + assertThat(matcher.matches("v2")).isFalse(); + + matcher = HeaderMatcher.forExactValue("version", "v1", true); + assertThat(matcher.matches("v1")).isFalse(); + assertThat(matcher.matches( "v2")).isTrue(); + + matcher = HeaderMatcher.forPresent("version", true, false); + assertThat(matcher.matches("any")).isTrue(); + assertThat(matcher.matches(null)).isFalse(); + matcher = HeaderMatcher.forPresent("version", true, true); + assertThat(matcher.matches("version")).isFalse(); + matcher = HeaderMatcher.forPresent("version", false, true); + assertThat(matcher.matches("tag")).isTrue(); + matcher = HeaderMatcher.forPresent("version", false, false); + assertThat(matcher.matches("tag")).isFalse(); + + matcher = HeaderMatcher.forPrefix("version", "v2", false); + assertThat(matcher.matches("v22")).isTrue(); + matcher = HeaderMatcher.forPrefix("version", "v2", true); + assertThat(matcher.matches("v22")).isFalse(); + + matcher = HeaderMatcher.forSuffix("version", "v1", false); + assertThat(matcher.matches("xv1")).isTrue(); + assertThat(matcher.matches("v1x")).isFalse(); + matcher = HeaderMatcher.forSuffix("version", "v2", true); + assertThat(matcher.matches("xv1")).isTrue(); + assertThat(matcher.matches("1v2")).isFalse(); + + matcher = HeaderMatcher.forSafeRegEx("version", Pattern.compile("v2.*"), false); + assertThat(matcher.matches("v2..")).isTrue(); + assertThat(matcher.matches("v1")).isFalse(); + matcher = HeaderMatcher.forSafeRegEx("version", Pattern.compile("v1\\..*"), true); + assertThat(matcher.matches("v1.43")).isFalse(); + assertThat(matcher.matches("v2")).isTrue(); + + matcher = HeaderMatcher.forRange("version", Range.create(8080L, 8090L), false); + assertThat(matcher.matches("8080")).isTrue(); + assertThat(matcher.matches("1")).isFalse(); + matcher = HeaderMatcher.forRange("version", Range.create(8080L, 8090L), true); + assertThat(matcher.matches("1")).isTrue(); + assertThat(matcher.matches("8080")).isFalse(); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/rbac/engine/GrpcAuthorizationEngineTest.java b/xds/src/test/java/io/grpc/xds/internal/rbac/engine/GrpcAuthorizationEngineTest.java new file mode 100644 index 0000000000..4ed637a998 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/rbac/engine/GrpcAuthorizationEngineTest.java @@ -0,0 +1,304 @@ +/* + * 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.internal.rbac.engine; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.google.common.collect.ImmutableList; +import io.grpc.Attributes; +import io.grpc.Grpc; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.MethodDescriptor.MethodType; +import io.grpc.ServerCall; +import io.grpc.internal.testing.TestUtils; +import io.grpc.testing.TestMethodDescriptors; +import io.grpc.xds.internal.Matchers; +import io.grpc.xds.internal.Matchers.CidrMatcher; +import io.grpc.xds.internal.Matchers.StringMatcher; +import io.grpc.xds.internal.rbac.engine.GrpcAuthorizationEngine.Action; +import io.grpc.xds.internal.rbac.engine.GrpcAuthorizationEngine.AlwaysTrueMatcher; +import io.grpc.xds.internal.rbac.engine.GrpcAuthorizationEngine.AndMatcher; +import io.grpc.xds.internal.rbac.engine.GrpcAuthorizationEngine.AuthConfig; +import io.grpc.xds.internal.rbac.engine.GrpcAuthorizationEngine.AuthDecision; +import io.grpc.xds.internal.rbac.engine.GrpcAuthorizationEngine.AuthenticatedMatcher; +import io.grpc.xds.internal.rbac.engine.GrpcAuthorizationEngine.DestinationIpMatcher; +import io.grpc.xds.internal.rbac.engine.GrpcAuthorizationEngine.DestinationPortMatcher; +import io.grpc.xds.internal.rbac.engine.GrpcAuthorizationEngine.HeaderMatcher; +import io.grpc.xds.internal.rbac.engine.GrpcAuthorizationEngine.InvertMatcher; +import io.grpc.xds.internal.rbac.engine.GrpcAuthorizationEngine.OrMatcher; +import io.grpc.xds.internal.rbac.engine.GrpcAuthorizationEngine.PathMatcher; +import io.grpc.xds.internal.rbac.engine.GrpcAuthorizationEngine.PolicyMatcher; +import io.grpc.xds.internal.rbac.engine.GrpcAuthorizationEngine.SourceIpMatcher; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.security.Principal; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import javax.net.ssl.SSLPeerUnverifiedException; +import javax.net.ssl.SSLSession; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +@RunWith(JUnit4.class) +public class GrpcAuthorizationEngineTest { + @Rule + public final MockitoRule mocks = MockitoJUnit.rule(); + + private static final String POLICY_NAME = "policy-name"; + private static final String HEADER_KEY = "header-key"; + private static final String HEADER_VALUE = "header-val"; + private static final String IP_ADDR1 = "10.10.10.0"; + private static final String IP_ADDR2 = "68.36.0.19"; + private static final int PORT = 100; + private static final String PATH = "/auth/engine"; + private static final StringMatcher STRING_MATCHER = StringMatcher.forExact("/" + PATH, false); + private static final Metadata HEADER = metadata(HEADER_KEY, HEADER_VALUE); + + @Mock + private ServerCall serverCall; + @Mock + private SSLSession sslSession; + + @Before + public void setUp() throws Exception { + X509Certificate[] certs = {TestUtils.loadX509Cert("server1.pem")}; + when(sslSession.getPeerCertificates()).thenReturn(certs); + Attributes attributes = Attributes.newBuilder() + .set(Grpc.TRANSPORT_ATTR_REMOTE_ADDR, new InetSocketAddress(IP_ADDR2, PORT)) + .set(Grpc.TRANSPORT_ATTR_LOCAL_ADDR, new InetSocketAddress(IP_ADDR1, PORT)) + .set(Grpc.TRANSPORT_ATTR_SSL_SESSION, sslSession) + .build(); + when(serverCall.getAttributes()).thenReturn(attributes); + when(serverCall.getMethodDescriptor()).thenReturn(method().build()); + } + + @Test + public void ipMatcher() throws Exception { + CidrMatcher ip1 = CidrMatcher.create(InetAddress.getByName(IP_ADDR1), 24); + DestinationIpMatcher destIpMatcher = new DestinationIpMatcher(ip1); + CidrMatcher ip2 = CidrMatcher.create(InetAddress.getByName(IP_ADDR2), 24); + SourceIpMatcher sourceIpMatcher = new SourceIpMatcher(ip2); + DestinationPortMatcher portMatcher = new DestinationPortMatcher(PORT); + OrMatcher permission = OrMatcher.create(AndMatcher.create(portMatcher, destIpMatcher)); + OrMatcher principal = OrMatcher.create(sourceIpMatcher); + PolicyMatcher policyMatcher = new PolicyMatcher(POLICY_NAME, permission, principal); + + GrpcAuthorizationEngine engine = new GrpcAuthorizationEngine( + new AuthConfig(Collections.singletonList(policyMatcher), Action.ALLOW)); + AuthDecision decision = engine.evaluate(HEADER, serverCall); + assertThat(decision.decision()).isEqualTo(Action.ALLOW); + assertThat(decision.matchingPolicyName()).isEqualTo(POLICY_NAME); + + Attributes attributes = Attributes.newBuilder() + .set(Grpc.TRANSPORT_ATTR_REMOTE_ADDR, new InetSocketAddress(IP_ADDR2, PORT)) + .set(Grpc.TRANSPORT_ATTR_LOCAL_ADDR, new InetSocketAddress(IP_ADDR1, 2)) + .build(); + when(serverCall.getAttributes()).thenReturn(attributes); + decision = engine.evaluate(HEADER, serverCall); + assertThat(decision.decision()).isEqualTo(Action.DENY); + assertThat(decision.matchingPolicyName()).isEqualTo(null); + + attributes = Attributes.newBuilder() + .set(Grpc.TRANSPORT_ATTR_REMOTE_ADDR, null) + .set(Grpc.TRANSPORT_ATTR_LOCAL_ADDR, new InetSocketAddress("1.1.1.1", PORT)) + .build(); + when(serverCall.getAttributes()).thenReturn(attributes); + decision = engine.evaluate(HEADER, serverCall); + assertThat(decision.decision()).isEqualTo(Action.DENY); + assertThat(decision.matchingPolicyName()).isEqualTo(null); + + engine = new GrpcAuthorizationEngine( + new AuthConfig(Collections.singletonList(policyMatcher), Action.DENY)); + decision = engine.evaluate(HEADER, serverCall); + assertThat(decision.decision()).isEqualTo(Action.ALLOW); + assertThat(decision.matchingPolicyName()).isEqualTo(null); + } + + @Test + public void headerMatcher() { + HeaderMatcher headerMatcher = new HeaderMatcher(Matchers.HeaderMatcher + .forExactValue(HEADER_KEY, HEADER_VALUE, false)); + OrMatcher principal = OrMatcher.create(headerMatcher); + OrMatcher permission = OrMatcher.create( + new InvertMatcher(new DestinationPortMatcher(PORT + 1))); + PolicyMatcher policyMatcher = new PolicyMatcher(POLICY_NAME, permission, principal); + GrpcAuthorizationEngine engine = new GrpcAuthorizationEngine( + new AuthConfig(Collections.singletonList(policyMatcher), Action.ALLOW)); + AuthDecision decision = engine.evaluate(HEADER, serverCall); + assertThat(decision.decision()).isEqualTo(Action.ALLOW); + assertThat(decision.matchingPolicyName()).isEqualTo(POLICY_NAME); + + HEADER.put(Metadata.Key.of(HEADER_KEY, Metadata.ASCII_STRING_MARSHALLER), HEADER_VALUE); + headerMatcher = new HeaderMatcher(Matchers.HeaderMatcher + .forExactValue(HEADER_KEY, HEADER_VALUE + "," + HEADER_VALUE, false)); + principal = OrMatcher.create(headerMatcher); + policyMatcher = new PolicyMatcher(POLICY_NAME, + OrMatcher.create(AlwaysTrueMatcher.INSTANCE), principal); + engine = new GrpcAuthorizationEngine( + new AuthConfig(Collections.singletonList(policyMatcher), Action.ALLOW)); + decision = engine.evaluate(HEADER, serverCall); + assertThat(decision.decision()).isEqualTo(Action.ALLOW); + + headerMatcher = new HeaderMatcher(Matchers.HeaderMatcher + .forExactValue(HEADER_KEY + Metadata.BINARY_HEADER_SUFFIX, HEADER_VALUE, false)); + principal = OrMatcher.create(headerMatcher); + policyMatcher = new PolicyMatcher(POLICY_NAME, + OrMatcher.create(AlwaysTrueMatcher.INSTANCE), principal); + engine = new GrpcAuthorizationEngine( + new AuthConfig(Collections.singletonList(policyMatcher), Action.ALLOW)); + decision = engine.evaluate(HEADER, serverCall); + assertThat(decision.decision()).isEqualTo(Action.DENY); + } + + @Test + public void pathMatcher() { + PathMatcher pathMatcher = new PathMatcher(STRING_MATCHER); + OrMatcher permission = OrMatcher.create(AlwaysTrueMatcher.INSTANCE); + OrMatcher principal = OrMatcher.create(pathMatcher); + PolicyMatcher policyMatcher = new PolicyMatcher(POLICY_NAME, permission, principal); + GrpcAuthorizationEngine engine = new GrpcAuthorizationEngine( + new AuthConfig(Collections.singletonList(policyMatcher), Action.DENY)); + AuthDecision decision = engine.evaluate(HEADER, serverCall); + assertThat(decision.decision()).isEqualTo(Action.DENY); + assertThat(decision.matchingPolicyName()).isEqualTo(POLICY_NAME); + } + + @Test + public void authenticatedMatcher() throws Exception { + AuthenticatedMatcher authMatcher = new AuthenticatedMatcher( + StringMatcher.forExact("*.test.google.fr", false)); + PathMatcher pathMatcher = new PathMatcher(STRING_MATCHER); + OrMatcher permission = OrMatcher.create(authMatcher); + OrMatcher principal = OrMatcher.create(pathMatcher); + PolicyMatcher policyMatcher = new PolicyMatcher(POLICY_NAME, permission, principal); + GrpcAuthorizationEngine engine = new GrpcAuthorizationEngine( + new AuthConfig(Collections.singletonList(policyMatcher), Action.ALLOW)); + AuthDecision decision = engine.evaluate(HEADER, serverCall); + assertThat(decision.decision()).isEqualTo(Action.ALLOW); + assertThat(decision.matchingPolicyName()).isEqualTo(POLICY_NAME); + + X509Certificate[] certs = {TestUtils.loadX509Cert("badserver.pem")}; + when(sslSession.getPeerCertificates()).thenReturn(certs); + decision = engine.evaluate(HEADER, serverCall); + assertThat(decision.decision()).isEqualTo(Action.DENY); + assertThat(decision.matchingPolicyName()).isEqualTo(null); + + X509Certificate mockCert = mock(X509Certificate.class); + when(sslSession.getPeerCertificates()).thenReturn(new X509Certificate[]{mockCert}); + assertThat(engine.evaluate(HEADER, serverCall).decision()).isEqualTo(Action.DENY); + when(mockCert.getSubjectDN()).thenReturn(mock(Principal.class)); + assertThat(engine.evaluate(HEADER, serverCall).decision()).isEqualTo(Action.DENY); + when(mockCert.getSubjectAlternativeNames()).thenReturn(Arrays.>asList( + Arrays.asList(2, "*.test.google.fr"))); + assertThat(engine.evaluate(HEADER, serverCall).decision()).isEqualTo(Action.ALLOW); + when(mockCert.getSubjectAlternativeNames()).thenReturn(Arrays.>asList( + Arrays.asList(6, "*.test.google.fr"))); + assertThat(engine.evaluate(HEADER, serverCall).decision()).isEqualTo(Action.ALLOW); + when(mockCert.getSubjectAlternativeNames()).thenReturn(Arrays.>asList( + Arrays.asList(10, "*.test.google.fr"))); + assertThat(engine.evaluate(HEADER, serverCall).decision()).isEqualTo(Action.DENY); + when(mockCert.getSubjectAlternativeNames()).thenReturn(Arrays.>asList( + Arrays.asList(2, "google.com"), Arrays.asList(6, "*.test.google.fr"))); + assertThat(engine.evaluate(HEADER, serverCall).decision()).isEqualTo(Action.ALLOW); + when(mockCert.getSubjectAlternativeNames()).thenReturn(Arrays.>asList( + Arrays.asList(6, "*.test.google.fr"), Arrays.asList(2, "google.com"))); + assertThat(engine.evaluate(HEADER, serverCall).decision()).isEqualTo(Action.ALLOW); + when(mockCert.getSubjectAlternativeNames()).thenReturn(Arrays.>asList( + Arrays.asList(2, "*.test.google.fr"), Arrays.asList(6, "google.com"))); + assertThat(engine.evaluate(HEADER, serverCall).decision()).isEqualTo(Action.DENY); + when(mockCert.getSubjectAlternativeNames()).thenReturn(Arrays.>asList( + Arrays.asList(2, "*.test.google.fr"), Arrays.asList(6, "google.com"), + Arrays.asList(6, "*.test.google.fr"))); + assertThat(engine.evaluate(HEADER, serverCall).decision()).isEqualTo(Action.ALLOW); + + // match any authenticated connection if StringMatcher not set in AuthenticatedMatcher + permission = OrMatcher.create(new AuthenticatedMatcher(null)); + policyMatcher = new PolicyMatcher(POLICY_NAME, permission, principal); + when(mockCert.getSubjectAlternativeNames()).thenReturn( + Arrays.>asList(Arrays.asList(6, "random"))); + engine = new GrpcAuthorizationEngine(new AuthConfig(Collections.singletonList(policyMatcher), + Action.ALLOW)); + assertThat(engine.evaluate(HEADER, serverCall).decision()).isEqualTo(Action.ALLOW); + + // not match any unauthenticated connection + Attributes attributes = Attributes.newBuilder() + .set(Grpc.TRANSPORT_ATTR_REMOTE_ADDR, new InetSocketAddress(IP_ADDR2, PORT)) + .set(Grpc.TRANSPORT_ATTR_LOCAL_ADDR, new InetSocketAddress(IP_ADDR1, PORT)) + .build(); + when(serverCall.getAttributes()).thenReturn(attributes); + assertThat(engine.evaluate(HEADER, serverCall).decision()).isEqualTo(Action.DENY); + + doThrow(new SSLPeerUnverifiedException("bad")).when(sslSession).getPeerCertificates(); + decision = engine.evaluate(HEADER, serverCall); + assertThat(decision.decision()).isEqualTo(Action.DENY); + assertThat(decision.matchingPolicyName()).isEqualTo(null); + } + + @Test + public void multiplePolicies() throws Exception { + AuthenticatedMatcher authMatcher = new AuthenticatedMatcher( + StringMatcher.forSuffix("TEST.google.fr", true)); + PathMatcher pathMatcher = new PathMatcher(STRING_MATCHER); + OrMatcher principal = OrMatcher.create(AndMatcher.create(authMatcher, pathMatcher)); + OrMatcher permission = OrMatcher.create(AndMatcher.create(pathMatcher, + new InvertMatcher(new DestinationPortMatcher(PORT + 1)))); + PolicyMatcher policyMatcher1 = new PolicyMatcher(POLICY_NAME, permission, principal); + + HeaderMatcher headerMatcher = new HeaderMatcher(Matchers.HeaderMatcher + .forExactValue(HEADER_KEY, HEADER_VALUE + 1, false)); + authMatcher = new AuthenticatedMatcher( + StringMatcher.forContains("TEST.google.fr")); + principal = OrMatcher.create(headerMatcher, authMatcher); + CidrMatcher ip1 = CidrMatcher.create(InetAddress.getByName(IP_ADDR1), 24); + DestinationIpMatcher destIpMatcher = new DestinationIpMatcher(ip1); + permission = OrMatcher.create(destIpMatcher, pathMatcher); + PolicyMatcher policyMatcher2 = new PolicyMatcher(POLICY_NAME + "-2", permission, principal); + + GrpcAuthorizationEngine engine = new GrpcAuthorizationEngine( + new AuthConfig(ImmutableList.of(policyMatcher1, policyMatcher2), Action.DENY)); + AuthDecision decision = engine.evaluate(HEADER, serverCall); + assertThat(decision.decision()).isEqualTo(Action.DENY); + assertThat(decision.matchingPolicyName()).isEqualTo(POLICY_NAME); + } + + private MethodDescriptor.Builder method() { + return MethodDescriptor.newBuilder() + .setType(MethodType.BIDI_STREAMING) + .setFullMethodName(PATH) + .setRequestMarshaller(TestMethodDescriptors.voidMarshaller()) + .setResponseMarshaller(TestMethodDescriptors.voidMarshaller()); + } + + private static Metadata metadata(String key, String value) { + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER), value); + return metadata; + } +}