/*
 * Licensed to the Apache Software Foundation (ASF) under one or more contributor license
 * agreements. See the NOTICE file distributed with this work for additional information regarding
 * copyright ownership. The ASF licenses this file to You under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance with the License. You may obtain a
 * copy of the License at
 *
 * 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 org.apache.geode.security.templates;

import static org.apache.geode.cache.Region.SEPARATOR_CHAR;

import java.io.IOException;
import java.io.InputStream;
import java.security.Principal;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;

import org.w3c.dom.Attr;
import org.w3c.dom.Document;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.EntityResolver;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.SAXParseException;

import org.apache.geode.LogWriter;
import org.apache.geode.cache.Cache;
import org.apache.geode.cache.operations.ExecuteFunctionOperationContext;
import org.apache.geode.cache.operations.OperationContext;
import org.apache.geode.cache.operations.OperationContext.OperationCode;
import org.apache.geode.cache.operations.QueryOperationContext;
import org.apache.geode.distributed.DistributedMember;
import org.apache.geode.security.AccessControl;
import org.apache.geode.security.NotAuthorizedException;

/**
 * An implementation of the {@link AccessControl} interface that allows authorization using the
 * permissions as specified in the given XML file.
 *
 * The format of the XML file is specified in <a href="authz5_5.dtd">authz5_5.dtd</a>. It implements
 * a role-based authorization at the operation level for each region. Each principal name may be
 * associated with a set of roles. The name of the principal is obtained using the
 * {@link Principal#getName()} method and no other information of the principal is utilized. Each
 * role can be provided permissions to execute operations for each region.
 *
 * The top-level element in the XML is "acl" tag that contains the "role" and "permission" tags. The
 * "role" tag contains the list of users that have been given that role. The name of the role is
 * specified in the "role" attribute and the users are contained in the "user" tags insided the
 * "role" tag.
 *
 * The "permissions" tag contains the list of operations allowed for a particular region. The role
 * name is specified as the "role" attribute, the list of comma separated region names as the
 * optional "regions" attribute and the operation names are contained in the "operation" tags inside
 * the "permissions" tag. The allowed operation names are: GET, PUT, PUTALL, DESTROY,
 * REGISTER_INTEREST, UNREGISTER_INTEREST, CONTAINS_KEY, KEY_SET, QUERY, EXECUTE_CQ, STOP_CQ,
 * CLOSE_CQ, REGION_CLEAR, REGION_CREATE, REGION_DESTROY. These correspond to the operations in the
 * {@link OperationCode} enumeration with the same name.
 *
 * When no region name is specified then the operation is allowed for all regions in the cache. Any
 * permissions specified for regions using the "regions" attribute override these permissions. This
 * allows users to provide generic permissions without any region name, and override for specific
 * regions specified using the "regions" attribute. A cache-level operation (e.g.
 * {@link OperationCode#REGION_DESTROY}) specified for a particular region is ignored i.e. the
 * cache-level operations are only applicable when no region name is specified. A
 * {@link OperationCode#QUERY} operation is permitted when either the {@code QUERY} permission is
 * provided at the cache-level for the user or when {@code QUERY} permission is provided for all the
 * regions that are part of the query string.
 *
 * Any roles specified in the "user" tag that do not have a specified permission set using the
 * "permission" tags are ignored. When no {@link Principal} is associated with the current
 * connection, then empty user name is used to search for the roles so an empty user name can be
 * used to specify roles of unauthenticated clients (i.e. {@code Everyone}).
 *
 * This sample implementation is useful only for pre-operation checks and should not be used for
 * post-operation authorization since it does nothing useful for post-operation case.
 *
 * @since GemFire 5.5
 */
public class XmlAuthorization implements AccessControl {

  public static final String DOC_URI_PROP_NAME = "security-authz-xml-uri";

  private static final Object sync = new Object();
  private static final String EMPTY_VALUE = "";

  private static final String TAG_ROLE = "role";
  private static final String TAG_USER = "user";
  private static final String TAG_PERMS = "permission";
  private static final String TAG_OP = "operation";

  private static final String ATTR_ROLENAME = "name";
  private static final String ATTR_ROLE = "role";
  private static final String ATTR_REGIONS = "regions";
  private static final String ATTR_FUNCTION_IDS = "functionIds";
  private static final String ATTR_FUNCTION_OPTIMIZE_FOR_WRITE = "optimizeForWrite";
  private static final String ATTR_FUNCTION_KEY_SET = "keySet";

  private static String currentDocUri = null;
  private static Map<String, HashSet<String>> userRoles = null;
  private static Map<String, Map<String, Map<OperationCode, FunctionSecurityPrmsHolder>>> rolePermissions =
      null;
  private static NotAuthorizedException xmlLoadFailure = null;

  private final Map<String, Map<OperationCode, FunctionSecurityPrmsHolder>> allowedOps;

  protected LogWriter systemLogWriter;
  protected LogWriter securityLogWriter;

  /**
   * Public static factory method to create an instance of {@code XmlAuthorization}. The fully
   * qualified name of the class
   * ({@code org.apache.geode.security.templates.XmlAuthorization.create}) should be mentioned as
   * the {@code security-client-accessor} system property to enable pre-operation authorization
   * checks as implemented in this class.
   *
   * @return an object of {@code XmlAuthorization} class
   */
  public static AccessControl create() {
    return new XmlAuthorization();
  }

  /**
   * Clear all the statically cached information.
   */
  public static void clear() {
    XmlAuthorization.currentDocUri = null;
    if (XmlAuthorization.userRoles != null) {
      XmlAuthorization.userRoles.clear();
      XmlAuthorization.userRoles = null;
    }
    if (XmlAuthorization.rolePermissions != null) {
      XmlAuthorization.rolePermissions.clear();
      XmlAuthorization.rolePermissions = null;
    }
    XmlAuthorization.xmlLoadFailure = null;
  }

  /*
   * Change the region name to a standard format having single '/' as separator and starting with a
   * '/' as in standard POSIX paths
   */
  public static String normalizeRegionName(final String regionName) {
    if (regionName == null || regionName.length() == 0) {
      return EMPTY_VALUE;
    }

    char[] resultName = new char[regionName.length() + 1];
    boolean changed = false;
    boolean isPrevCharSlash = false;
    int startIndex;

    if (regionName.charAt(0) != SEPARATOR_CHAR) {
      changed = true;
      startIndex = 0;
    } else {
      isPrevCharSlash = true;
      startIndex = 1;
    }

    resultName[0] = SEPARATOR_CHAR;
    int resultLength = 1;

    // Replace all more than one '/'s with a single '/'
    for (int index = startIndex; index < regionName.length(); ++index) {
      char currChar = regionName.charAt(index);
      if (currChar == SEPARATOR_CHAR) {
        if (isPrevCharSlash) {
          changed = true;
          continue;
        }
        isPrevCharSlash = true;
      } else {
        isPrevCharSlash = false;
      }
      resultName[resultLength++] = currChar;
    }

    // Remove any trailing slash
    if (resultName[resultLength - 1] == SEPARATOR_CHAR) {
      --resultLength;
      changed = true;
    }

    if (changed) {
      return new String(resultName, 0, resultLength);
    } else {
      return regionName;
    }
  }

  private XmlAuthorization() {
    allowedOps = new HashMap<>();
    systemLogWriter = null;
    securityLogWriter = null;
  }

  /**
   * Initialize the {@code XmlAuthorization} callback for a client having the given principal.
   *
   * This method caches the full XML authorization file the first time it is invoked and caches all
   * the permissions for the provided {@code principal} to speed up lookup the
   * {@code authorizeOperation} calls. The permissions for the principal are maintained as a
   * {@link Map} of region name to the {@link HashSet} of operations allowed for that region. A
   * global entry with region name as empty string is also made for permissions provided for all the
   * regions.
   *
   * @param principal the principal associated with the authenticated client
   * @param cache reference to the cache object
   * @param remoteMember the {@link DistributedMember} object for the remote authenticated client
   *
   * @throws NotAuthorizedException if some exception condition happens during the initialization
   *         while reading the XML; in such a case all subsequent client operations will throw
   *         {@code NotAuthorizedException}
   */
  @Override
  public void init(final Principal principal, final DistributedMember remoteMember,
      final Cache cache) throws NotAuthorizedException {
    synchronized (sync) {
      XmlAuthorization.init(cache);
    }

    systemLogWriter = cache.getLogger();
    securityLogWriter = cache.getSecurityLogger();

    String name;
    if (principal != null) {
      name = principal.getName();
    } else {
      name = EMPTY_VALUE;
    }

    HashSet<String> roles = XmlAuthorization.userRoles.get(name);
    if (roles != null) {
      for (String roleName : roles) {
        Map<String, Map<OperationCode, FunctionSecurityPrmsHolder>> regionOperationMap =
            XmlAuthorization.rolePermissions.get(roleName);
        if (regionOperationMap != null) {
          for (Map.Entry<String, Map<OperationCode, FunctionSecurityPrmsHolder>> regionEntry : regionOperationMap
              .entrySet()) {
            String regionName = regionEntry.getKey();
            Map<OperationCode, FunctionSecurityPrmsHolder> regionOperations =
                allowedOps.get(regionName);
            if (regionOperations == null) {
              regionOperations = new HashMap<>();
              allowedOps.put(regionName, regionOperations);
            }
            regionOperations.putAll(regionEntry.getValue());
          }
        }
      }
    }
  }

  /**
   * Return true if the given operation is allowed for the cache/region.
   *
   * This looks up the cached permissions of the principal in the map for the provided region name.
   * If none are found then the global permissions with empty region name are looked up. The
   * operation is allowed if it is found this permission list.
   *
   * @param regionName When null then it indicates a cache-level operation, else the name of the
   *        region for the operation.
   * @param context the data required by the operation
   *
   * @return true if the operation is authorized and false otherwise
   */
  @Override
  public boolean authorizeOperation(String regionName, final OperationContext context) {
    Map<OperationCode, FunctionSecurityPrmsHolder> operationMap;

    // Check GET permissions for updates from server to client
    if (context.isClientUpdate()) {
      operationMap = allowedOps.get(regionName);
      if (operationMap == null && regionName.length() > 0) {
        operationMap = allowedOps.get(EMPTY_VALUE);
      }
      if (operationMap != null) {
        return operationMap.containsKey(OperationCode.GET);
      }
      return false;
    }

    OperationCode opCode = context.getOperationCode();
    if (opCode.isQuery() || opCode.isExecuteCQ() || opCode.isCloseCQ() || opCode.isStopCQ()) {
      // First check if cache-level permission has been provided
      operationMap = allowedOps.get(EMPTY_VALUE);
      boolean globalPermission = (operationMap != null && operationMap.containsKey(opCode));
      Set<String> regionNames = ((QueryOperationContext) context).getRegionNames();
      if (regionNames == null || regionNames.size() == 0) {
        return globalPermission;
      }

      for (String r : regionNames) {
        regionName = normalizeRegionName(r);
        operationMap = allowedOps.get(regionName);
        if (operationMap == null) {
          if (!globalPermission) {
            return false;
          }
        } else if (!operationMap.containsKey(opCode)) {
          return false;
        }
      }
      return true;
    }

    final String normalizedRegionName = normalizeRegionName(regionName);
    operationMap = allowedOps.get(normalizedRegionName);
    if (operationMap == null && normalizedRegionName.length() > 0) {
      operationMap = allowedOps.get(EMPTY_VALUE);
    }
    if (operationMap != null) {
      if (context.getOperationCode() != OperationCode.EXECUTE_FUNCTION) {
        return operationMap.containsKey(context.getOperationCode());

      } else {
        if (!operationMap.containsKey(context.getOperationCode())) {
          return false;

        } else {
          if (!context.isPostOperation()) {
            FunctionSecurityPrmsHolder functionParameter =
                operationMap.get(context.getOperationCode());
            ExecuteFunctionOperationContext functionContext =
                (ExecuteFunctionOperationContext) context;
            // OnRegion execution
            if (functionContext.getRegionName() != null) {
              if (functionParameter.isOptimizeForWrite() != null && functionParameter
                  .isOptimizeForWrite() != functionContext.isOptimizeForWrite()) {
                return false;
              }
              if (functionParameter.getFunctionIds() != null && !functionParameter.getFunctionIds()
                  .contains(functionContext.getFunctionId())) {
                return false;
              }
              if (functionParameter.getKeySet() != null && functionContext.getKeySet() != null) {
                return !functionContext.getKeySet().containsAll(functionParameter.getKeySet());
              }
              return true;

            } else {// On Server execution
              return functionParameter.getFunctionIds() == null
                  || functionParameter.getFunctionIds()
                      .contains(functionContext.getFunctionId());
            }

          } else {
            ExecuteFunctionOperationContext functionContext =
                (ExecuteFunctionOperationContext) context;
            FunctionSecurityPrmsHolder functionParameter =
                operationMap.get(context.getOperationCode());
            if (functionContext.getRegionName() != null) {
              if (functionContext.getResult() instanceof ArrayList
                  && functionParameter.getKeySet() != null) {
                ArrayList<String> resultList = (ArrayList) functionContext.getResult();
                Set<String> nonAllowedKeys = functionParameter.getKeySet();
                return !resultList.containsAll(nonAllowedKeys);
              }
              return true;

            } else {
              ArrayList<String> resultList = (ArrayList) functionContext.getResult();
              final String inSecureItem = "Insecure item";
              return !resultList.contains(inSecureItem);
            }
          }
        }
      }
    }
    return false;
  }

  /**
   * Clears the cached information for this principal.
   */
  @Override
  public void close() {
    allowedOps.clear();
  }

  /** Get the attribute value for a given attribute name of a node. */
  private static String getAttributeValue(final Node node, final String attrName) {
    NamedNodeMap attrMap = node.getAttributes();
    Node attrNode;
    if (attrMap != null && (attrNode = attrMap.getNamedItem(attrName)) != null) {
      return ((Attr) attrNode).getValue();
    }
    return EMPTY_VALUE;
  }

  /** Get the string contained in the first text child of the node. */
  private static String getNodeValue(final Node node) {
    NodeList childNodes = node.getChildNodes();
    for (int index = 0; index < childNodes.getLength(); index++) {
      Node childNode = childNodes.item(index);
      if (childNode.getNodeType() == Node.TEXT_NODE) {
        return childNode.getNodeValue();
      }
    }
    return EMPTY_VALUE;
  }

  /**
   * Cache authorization information for all users statically. This method is not thread-safe and is
   * should either be invoked only once, or the caller should take the appropriate locks.
   *
   * @param cache reference to the cache object for the distributed system
   */
  private static void init(final Cache cache) throws NotAuthorizedException {
    final LogWriter systemLogWriter = cache.getLogger();
    final String xmlDocumentUri =
        (String) cache.getDistributedSystem().getSecurityProperties().get(DOC_URI_PROP_NAME);

    try {
      if (xmlDocumentUri == null) {
        throw new NotAuthorizedException(
            "No ACL file defined using tag [" + DOC_URI_PROP_NAME + "] in system properties");
      }
      if (xmlDocumentUri.equals(XmlAuthorization.currentDocUri)) {
        if (XmlAuthorization.xmlLoadFailure != null) {
          throw XmlAuthorization.xmlLoadFailure;
        }
        return;
      }

      final DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
      factory.setIgnoringComments(true);
      factory.setIgnoringElementContentWhitespace(true);
      factory.setValidating(true);

      final DocumentBuilder builder = factory.newDocumentBuilder();
      final XmlErrorHandler errorHandler = new XmlErrorHandler(systemLogWriter, xmlDocumentUri);
      builder.setErrorHandler(errorHandler);
      builder.setEntityResolver(new AuthzDtdResolver());

      final Document xmlDocument = builder.parse(xmlDocumentUri);

      XmlAuthorization.userRoles = new HashMap<>();
      XmlAuthorization.rolePermissions =
          new HashMap<>();

      final NodeList roleUserNodes = xmlDocument.getElementsByTagName(TAG_ROLE);

      for (int roleIndex = 0; roleIndex < roleUserNodes.getLength(); roleIndex++) {
        final Node roleUserNode = roleUserNodes.item(roleIndex);
        final String roleName = getAttributeValue(roleUserNode, ATTR_ROLENAME);
        final NodeList userNodes = roleUserNode.getChildNodes();

        for (int userIndex = 0; userIndex < userNodes.getLength(); userIndex++) {
          final Node userNode = userNodes.item(userIndex);

          if (TAG_USER.equals(userNode.getNodeName())) {
            final String userName = getNodeValue(userNode);
            HashSet<String> userRoleSet = XmlAuthorization.userRoles.get(userName);
            if (userRoleSet == null) {
              userRoleSet = new HashSet<>();
              XmlAuthorization.userRoles.put(userName, userRoleSet);
            }
            userRoleSet.add(roleName);

          } else {
            throw new SAXParseException(
                "Unknown tag [" + userNode.getNodeName() + "] as child of tag [" + TAG_ROLE + ']',
                null);
          }
        }
      }

      final NodeList rolePermissionNodes = xmlDocument.getElementsByTagName(TAG_PERMS);

      for (int permIndex = 0; permIndex < rolePermissionNodes.getLength(); permIndex++) {
        final Node rolePermissionNode = rolePermissionNodes.item(permIndex);
        final String roleName = getAttributeValue(rolePermissionNode, ATTR_ROLE);
        Map<String, Map<OperationCode, FunctionSecurityPrmsHolder>> regionOperationMap =
            XmlAuthorization.rolePermissions.get(roleName);

        if (regionOperationMap == null) {
          regionOperationMap =
              new HashMap<>();
          XmlAuthorization.rolePermissions.put(roleName, regionOperationMap);
        }

        final NodeList operationNodes = rolePermissionNode.getChildNodes();
        final HashMap<OperationCode, FunctionSecurityPrmsHolder> operationMap =
            new HashMap<>();

        for (int opIndex = 0; opIndex < operationNodes.getLength(); opIndex++) {
          final Node operationNode = operationNodes.item(opIndex);

          if (TAG_OP.equals(operationNode.getNodeName())) {
            final String operationName = getNodeValue(operationNode);
            final OperationCode code = OperationCode.valueOf(operationName);

            if (code == null) {
              throw new SAXParseException("Unknown operation [" + operationName + ']', null);
            }

            if (code != OperationCode.EXECUTE_FUNCTION) {
              operationMap.put(code, null);

            } else {
              final String optimizeForWrite =
                  getAttributeValue(operationNode, ATTR_FUNCTION_OPTIMIZE_FOR_WRITE);
              final String functionAttr = getAttributeValue(operationNode, ATTR_FUNCTION_IDS);
              final String keysAttr = getAttributeValue(operationNode, ATTR_FUNCTION_KEY_SET);

              Boolean isOptimizeForWrite;
              HashSet<String> functionIds;
              HashSet<String> keySet;

              if (optimizeForWrite == null || optimizeForWrite.length() == 0) {
                isOptimizeForWrite = null;
              } else {
                isOptimizeForWrite = Boolean.parseBoolean(optimizeForWrite);
              }

              if (functionAttr == null || functionAttr.length() == 0) {
                functionIds = null;
              } else {
                final String[] functionArray = functionAttr.split(",");
                functionIds = new HashSet<>();
                for (final String s : functionArray) {
                  functionIds.add(s);
                }
              }

              if (keysAttr == null || keysAttr.length() == 0) {
                keySet = null;
              } else {
                final String[] keySetArray = keysAttr.split(",");
                keySet = new HashSet<>();
                for (final String s : keySetArray) {
                  keySet.add(s);
                }
              }

              final FunctionSecurityPrmsHolder functionContext =
                  new FunctionSecurityPrmsHolder(isOptimizeForWrite, functionIds, keySet);
              operationMap.put(code, functionContext);
            }

          } else {
            throw new SAXParseException("Unknown tag [" + operationNode.getNodeName()
                + "] as child of tag [" + TAG_PERMS + ']', null);
          }
        }

        final String regionNames = getAttributeValue(rolePermissionNode, ATTR_REGIONS);
        if (regionNames == null || regionNames.length() == 0) {
          regionOperationMap.put(EMPTY_VALUE, operationMap);
        } else {
          final String[] regionNamesSplit = regionNames.split(",");
          for (final String s : regionNamesSplit) {
            regionOperationMap.put(normalizeRegionName(s), operationMap);
          }
        }
      }
      XmlAuthorization.currentDocUri = xmlDocumentUri;

    } catch (Exception ex) {
      String message;
      if (ex instanceof NotAuthorizedException) {
        message = ex.getMessage();
      } else {
        message = ex.getClass().getName() + ": " + ex.getMessage();
      }
      systemLogWriter.warning("XmlAuthorization.init: " + message);
      XmlAuthorization.xmlLoadFailure = new NotAuthorizedException(message, ex);
      throw XmlAuthorization.xmlLoadFailure;
    }
  }

  private static class AuthzDtdResolver implements EntityResolver {
    final Pattern authzPattern = Pattern.compile("authz.*\\.dtd");

    @Override
    public InputSource resolveEntity(final String publicId, final String systemId)
        throws SAXException, IOException {
      try {
        final Matcher matcher = authzPattern.matcher(systemId);
        if (matcher.find()) {
          final String dtdName = matcher.group(0);
          final InputStream stream = XmlAuthorization.class.getResourceAsStream(dtdName);
          return new InputSource(stream);
        }

      } catch (Exception e) {
        // do nothing, use the default resolver
      }

      return null;
    }
  }
}
