/*
 * 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 com.google.common.collect.ImmutableList;
import com.google.common.io.BaseEncoding;
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.Locale;
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
 *
 * <p>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;
    }
    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. */
  @AutoValue
  public abstract static class AuthConfig {
    public abstract ImmutableList<PolicyMatcher> policies();

    public abstract Action action();

    public static AuthConfig create(List<PolicyMatcher> policies, Action action) {
      return new AutoValue_GrpcAuthorizationEngine_AuthConfig(
          ImmutableList.copyOf(policies), 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.
   *
   * <p>Currently we only support matching some of the request fields. Those unsupported fields are
   * considered not match until we stop ignoring them.
   */
  @AutoValue
  public abstract static class PolicyMatcher implements Matcher {
    public abstract String name();

    public abstract OrMatcher permissions();

    public abstract OrMatcher principals();

    /** Constructs a matcher for one RBAC policy. */
    public static PolicyMatcher create(String name, OrMatcher permissions, OrMatcher principals) {
      return new AutoValue_GrpcAuthorizationEngine_PolicyMatcher(name, permissions, principals);
    }

    @Override
    public boolean matches(EvaluateArgs args) {
      return permissions().matches(args) && principals().matches(args);
    }
  }

  @AutoValue
  public abstract static class AuthenticatedMatcher implements Matcher {
    @Nullable
    public abstract Matchers.StringMatcher delegate();

    /**
     * Passing in null will match all authenticated user, i.e. SSL session is present.
     * https://github.com/envoyproxy/envoy/blob/3975bf5dadb43421907bbc52df57c0e8539c9a06/api/envoy/config/rbac/v3/rbac.proto#L253
     * */
    public static AuthenticatedMatcher create(@Nullable Matchers.StringMatcher delegate) {
      return new AutoValue_GrpcAuthorizationEngine_AuthenticatedMatcher(delegate);
    }

    @Override
    public boolean matches(EvaluateArgs args) {
      Collection<String> 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;
    }
  }

  @AutoValue
  public abstract static class DestinationIpMatcher implements Matcher {
    public abstract Matchers.CidrMatcher delegate();

    public static DestinationIpMatcher create(Matchers.CidrMatcher delegate) {
      return new AutoValue_GrpcAuthorizationEngine_DestinationIpMatcher(delegate);
    }

    @Override
    public boolean matches(EvaluateArgs args) {
      return delegate().matches(args.getDestinationIp());
    }
  }

  @AutoValue
  public abstract static class SourceIpMatcher implements Matcher {
    public abstract Matchers.CidrMatcher delegate();

    public static SourceIpMatcher create(Matchers.CidrMatcher delegate) {
      return new AutoValue_GrpcAuthorizationEngine_SourceIpMatcher(delegate);
    }

    @Override
    public boolean matches(EvaluateArgs args) {
      return delegate().matches(args.getSourceIp());
    }
  }

  @AutoValue
  public abstract static class PathMatcher implements Matcher {
    public abstract Matchers.StringMatcher delegate();

    public static PathMatcher create(Matchers.StringMatcher delegate) {
      return new AutoValue_GrpcAuthorizationEngine_PathMatcher(delegate);
    }

    @Override
    public boolean matches(EvaluateArgs args) {
      return delegate().matches(args.getPath());
    }
  }

  @AutoValue
  public abstract static class AuthHeaderMatcher implements Matcher {
    public abstract Matchers.HeaderMatcher delegate();

    public static AuthHeaderMatcher create(Matchers.HeaderMatcher delegate) {
      return new AutoValue_GrpcAuthorizationEngine_AuthHeaderMatcher(delegate);
    }

    @Override
    public boolean matches(EvaluateArgs args) {
      return delegate().matches(args.getHeader(delegate().name()));
    }
  }

  @AutoValue
  public abstract static class DestinationPortMatcher implements Matcher {
    public abstract int port();

    public static DestinationPortMatcher create(int port) {
      return new AutoValue_GrpcAuthorizationEngine_DestinationPortMatcher(port);
    }

    @Override
    public boolean matches(EvaluateArgs args) {
      return port() == args.getDestinationPort();
    }
  }

  @AutoValue
  public abstract static class DestinationPortRangeMatcher implements Matcher {
    public abstract int start();

    public abstract int end();

    /** Start of the range is inclusive. End of the range is exclusive.*/
    public static DestinationPortRangeMatcher create(int start, int end) {
      return new AutoValue_GrpcAuthorizationEngine_DestinationPortRangeMatcher(start, end);
    }

    @Override
    public boolean matches(EvaluateArgs args) {
      int port = args.getDestinationPort();
      return  port >= start() && port < end();
    }
  }

  @AutoValue
  public abstract static class RequestedServerNameMatcher implements Matcher {
    public abstract Matchers.StringMatcher delegate();

    public static RequestedServerNameMatcher create(Matchers.StringMatcher delegate) {
      return new AutoValue_GrpcAuthorizationEngine_RequestedServerNameMatcher(delegate);
    }

    @Override
    public boolean matches(EvaluateArgs args) {
      return delegate().matches(args.getRequestedServerName());
    }
  }

  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
     */
    @Nullable
    private Collection<String> 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<List<?>> names = cert.getSubjectAlternativeNames();
        List<String> 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.getSubjectX500Principal() == null
            || cert.getSubjectX500Principal().getName() == null) {
          return Collections.singleton("");
        }
        return Collections.singleton(cert.getSubjectX500Principal().getName());
      } catch (SSLPeerUnverifiedException | CertificateParsingException ex) {
        log.log(Level.FINE, "Unexpected getPrincipalNames error.", ex);
        return Collections.singleton("");
      }
    }

    @Nullable
    private String getHeader(String headerName) {
      headerName = headerName.toLowerCase(Locale.ROOT);
      if ("te".equals(headerName)) {
        return null;
      }
      if (":authority".equals(headerName)) {
        headerName = "host";
      }
      if ("host".equals(headerName)) {
        return serverCall.getAuthority();
      }
      if (":path".equals(headerName)) {
        return getPath();
      }
      if (":method".equals(headerName)) {
        return "POST";
      }
      return deserializeHeader(headerName);
    }

    @Nullable
    private String deserializeHeader(String headerName) {
      if (headerName.endsWith(Metadata.BINARY_HEADER_SUFFIX)) {
        Metadata.Key<byte[]> key;
        try {
          key = Metadata.Key.of(headerName, Metadata.BINARY_BYTE_MARSHALLER);
        } catch (IllegalArgumentException e) {
          return null;
        }
        Iterable<byte[]> values = metadata.getAll(key);
        if (values == null) {
          return null;
        }
        List<String> encoded = new ArrayList<>();
        for (byte[] v : values) {
          encoded.add(BaseEncoding.base64().omitPadding().encode(v));
        }
        return Joiner.on(",").join(encoded);
      }
      Metadata.Key<String> key;
      try {
        key = Metadata.Key.of(headerName, Metadata.ASCII_STRING_MARSHALLER);
      } catch (IllegalArgumentException e) {
        return null;
      }
      Iterable<String> 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();
    }

    private String getRequestedServerName() {
      return "";
    }
  }

  public interface Matcher {
    boolean matches(EvaluateArgs args);
  }

  @AutoValue
  public abstract static class OrMatcher implements Matcher {
    public abstract ImmutableList<? extends Matcher> anyMatch();

    /** Matches when any of the matcher matches. */
    public static OrMatcher create(List<? extends Matcher> matchers) {
      checkNotNull(matchers, "matchers");
      for (Matcher matcher : matchers) {
        checkNotNull(matcher, "matcher");
      }
      return new AutoValue_GrpcAuthorizationEngine_OrMatcher(ImmutableList.copyOf(matchers));
    }

    public static OrMatcher create(Matcher...matchers) {
      return OrMatcher.create(Arrays.asList(matchers));
    }

    @Override
    public boolean matches(EvaluateArgs args) {
      for (Matcher m : anyMatch()) {
        if (m.matches(args)) {
          return true;
        }
      }
      return false;
    }
  }

  @AutoValue
  public abstract static class AndMatcher implements Matcher {
    public abstract ImmutableList<? extends Matcher> allMatch();

    /** Matches when all of the matchers match. */
    public static AndMatcher create(List<? extends Matcher> matchers) {
      checkNotNull(matchers, "matchers");
      for (Matcher matcher : matchers) {
        checkNotNull(matcher, "matcher");
      }
      return new AutoValue_GrpcAuthorizationEngine_AndMatcher(ImmutableList.copyOf(matchers));
    }

    public static AndMatcher create(Matcher...matchers) {
      return AndMatcher.create(Arrays.asList(matchers));
    }

    @Override
    public boolean matches(EvaluateArgs args) {
      for (Matcher m : allMatch()) {
        if (!m.matches(args)) {
          return false;
        }
      }
      return true;
    }
  }

  /** Always true matcher.*/
  @AutoValue
  public abstract static class AlwaysTrueMatcher implements Matcher {
    public static AlwaysTrueMatcher INSTANCE =
        new AutoValue_GrpcAuthorizationEngine_AlwaysTrueMatcher();

    @Override
    public boolean matches(EvaluateArgs args) {
      return true;
    }
  }

  /** Negate matcher.*/
  @AutoValue
  public abstract static class InvertMatcher implements Matcher {
    public abstract Matcher toInvertMatcher();

    public static InvertMatcher create(Matcher matcher) {
      return new AutoValue_GrpcAuthorizationEngine_InvertMatcher(matcher);
    }

    @Override
    public boolean matches(EvaluateArgs args) {
      return !toInvertMatcher().matches(args);
    }
  }
}
