/*
 * Copyright (c) 2003, 2008, Oracle and/or its affiliates. All rights reserved.
 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
 *
 * This code is free software; you can redistribute it and/or modify it
 * under the terms of the GNU General Public License version 2 only, as
 * published by the Free Software Foundation.  Oracle designates this
 * particular file as subject to the "Classpath" exception as provided
 * by Oracle in the LICENSE file that accompanied this code.
 *
 * This code is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
 * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
 * version 2 for more details (a copy is included in the LICENSE file that
 * accompanied this code).
 *
 * You should have received a copy of the GNU General Public License version
 * 2 along with this work; if not, write to the Free Software Foundation,
 * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
 *
 * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
 * or visit www.oracle.com if you need additional information or have any
 * questions.
 */

package com.sun.jmx.remote.security;

import java.io.FileInputStream;
import java.io.IOException;
import java.security.AccessControlContext;
import java.security.AccessController;
import java.security.Principal;
import java.security.PrivilegedAction;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.StringTokenizer;
import java.util.regex.Pattern;
import javax.management.MBeanServer;
import javax.management.ObjectName;
import javax.security.auth.Subject;

/**
 * <p>An object of this class implements the MBeanServerAccessController
 * interface and, for each of its methods, calls an appropriate checking
 * method and then forwards the request to a wrapped MBeanServer object.
 * The checking method may throw a SecurityException if the operation is
 * not allowed; in this case the request is not forwarded to the
 * wrapped object.</p>
 *
 * <p>This class implements the {@link #checkRead()}, {@link #checkWrite()},
 * {@link #checkCreate(String)}, and {@link #checkUnregister(ObjectName)}
 * methods based on an access level properties file containing username/access
 * level pairs. The set of username/access level pairs is passed either as a
 * filename which denotes a properties file on disk, or directly as an instance
 * of the {@link Properties} class.  In both cases, the name of each property
 * represents a username, and the value of the property is the associated access
 * level.  Thus, any given username either does not exist in the properties or
 * has exactly one access level. The same access level can be shared by several
 * usernames.</p>
 *
 * <p>The supported access level values are {@code readonly} and
 * {@code readwrite}.  The {@code readwrite} access level can be
 * qualified by one or more <i>clauses</i>, where each clause looks
 * like <code>create <i>classNamePattern</i></code> or {@code
 * unregister}.  For example:</p>
 *
 * <pre>
 * monitorRole  readonly
 * controlRole  readwrite \
 *              create javax.management.timer.*,javax.management.monitor.* \
 *              unregister
 * </pre>
 *
 * <p>(The continuation lines with {@code \} come from the parser for
 * Properties files.)</p>
 */
public class MBeanServerFileAccessController
    extends MBeanServerAccessController {

    static final String READONLY = "readonly";
    static final String READWRITE = "readwrite";

    static final String CREATE = "create";
    static final String UNREGISTER = "unregister";

    private enum AccessType {READ, WRITE, CREATE, UNREGISTER};

    private static class Access {
        final boolean write;
        final String[] createPatterns;
        private boolean unregister;

        Access(boolean write, boolean unregister, List<String> createPatternList) {
            this.write = write;
            int npats = (createPatternList == null) ? 0 : createPatternList.size();
            if (npats == 0)
                this.createPatterns = NO_STRINGS;
            else
                this.createPatterns = createPatternList.toArray(new String[npats]);
            this.unregister = unregister;
        }

        private final String[] NO_STRINGS = new String[0];
    }

    /**
     * <p>Create a new MBeanServerAccessController that forwards all the
     * MBeanServer requests to the MBeanServer set by invoking the {@link
     * #setMBeanServer} method after doing access checks based on read and
     * write permissions.</p>
     *
     * <p>This instance is initialized from the specified properties file.</p>
     *
     * @param accessFileName name of the file which denotes a properties
     * file on disk containing the username/access level entries.
     *
     * @exception IOException if the file does not exist, is a
     * directory rather than a regular file, or for some other
     * reason cannot be opened for reading.
     *
     * @exception IllegalArgumentException if any of the supplied access
     * level values differs from "readonly" or "readwrite".
     */
    public MBeanServerFileAccessController(String accessFileName)
        throws IOException {
        super();
        this.accessFileName = accessFileName;
        Properties props = propertiesFromFile(accessFileName);
        parseProperties(props);
    }

    /**
     * <p>Create a new MBeanServerAccessController that forwards all the
     * MBeanServer requests to <code>mbs</code> after doing access checks
     * based on read and write permissions.</p>
     *
     * <p>This instance is initialized from the specified properties file.</p>
     *
     * @param accessFileName name of the file which denotes a properties
     * file on disk containing the username/access level entries.
     *
     * @param mbs the MBeanServer object to which requests will be forwarded.
     *
     * @exception IOException if the file does not exist, is a
     * directory rather than a regular file, or for some other
     * reason cannot be opened for reading.
     *
     * @exception IllegalArgumentException if any of the supplied access
     * level values differs from "readonly" or "readwrite".
     */
    public MBeanServerFileAccessController(String accessFileName,
                                           MBeanServer mbs)
        throws IOException {
        this(accessFileName);
        setMBeanServer(mbs);
    }

    /**
     * <p>Create a new MBeanServerAccessController that forwards all the
     * MBeanServer requests to the MBeanServer set by invoking the {@link
     * #setMBeanServer} method after doing access checks based on read and
     * write permissions.</p>
     *
     * <p>This instance is initialized from the specified properties
     * instance.  This constructor makes a copy of the properties
     * instance and it is the copy that is consulted to check the
     * username and access level of an incoming connection. The
     * original properties object can be modified without affecting
     * the copy. If the {@link #refresh} method is then called, the
     * <code>MBeanServerFileAccessController</code> will make a new
     * copy of the properties object at that time.</p>
     *
     * @param accessFileProps properties list containing the username/access
     * level entries.
     *
     * @exception IllegalArgumentException if <code>accessFileProps</code> is
     * <code>null</code> or if any of the supplied access level values differs
     * from "readonly" or "readwrite".
     */
    public MBeanServerFileAccessController(Properties accessFileProps)
        throws IOException {
        super();
        if (accessFileProps == null)
            throw new IllegalArgumentException("Null properties");
        originalProps = accessFileProps;
        parseProperties(accessFileProps);
    }

    /**
     * <p>Create a new MBeanServerAccessController that forwards all the
     * MBeanServer requests to the MBeanServer set by invoking the {@link
     * #setMBeanServer} method after doing access checks based on read and
     * write permissions.</p>
     *
     * <p>This instance is initialized from the specified properties
     * instance.  This constructor makes a copy of the properties
     * instance and it is the copy that is consulted to check the
     * username and access level of an incoming connection. The
     * original properties object can be modified without affecting
     * the copy. If the {@link #refresh} method is then called, the
     * <code>MBeanServerFileAccessController</code> will make a new
     * copy of the properties object at that time.</p>
     *
     * @param accessFileProps properties list containing the username/access
     * level entries.
     *
     * @param mbs the MBeanServer object to which requests will be forwarded.
     *
     * @exception IllegalArgumentException if <code>accessFileProps</code> is
     * <code>null</code> or if any of the supplied access level values differs
     * from "readonly" or "readwrite".
     */
    public MBeanServerFileAccessController(Properties accessFileProps,
                                           MBeanServer mbs)
        throws IOException {
        this(accessFileProps);
        setMBeanServer(mbs);
    }

    /**
     * Check if the caller can do read operations. This method does
     * nothing if so, otherwise throws SecurityException.
     */
    @Override
    public void checkRead() {
        checkAccess(AccessType.READ, null);
    }

    /**
     * Check if the caller can do write operations.  This method does
     * nothing if so, otherwise throws SecurityException.
     */
    @Override
    public void checkWrite() {
        checkAccess(AccessType.WRITE, null);
    }

    /**
     * Check if the caller can create MBeans or instances of the given class.
     * This method does nothing if so, otherwise throws SecurityException.
     */
    @Override
    public void checkCreate(String className) {
        checkAccess(AccessType.CREATE, className);
    }

    /**
     * Check if the caller can do unregister operations.  This method does
     * nothing if so, otherwise throws SecurityException.
     */
    @Override
    public void checkUnregister(ObjectName name) {
        checkAccess(AccessType.UNREGISTER, null);
    }

    /**
     * <p>Refresh the set of username/access level entries.</p>
     *
     * <p>If this instance was created using the
     * {@link #MBeanServerFileAccessController(String)} or
     * {@link #MBeanServerFileAccessController(String,MBeanServer)}
     * constructors to specify a file from which the entries are read,
     * the file is re-read.</p>
     *
     * <p>If this instance was created using the
     * {@link #MBeanServerFileAccessController(Properties)} or
     * {@link #MBeanServerFileAccessController(Properties,MBeanServer)}
     * constructors then a new copy of the <code>Properties</code> object
     * is made.</p>
     *
     * @exception IOException if the file does not exist, is a
     * directory rather than a regular file, or for some other
     * reason cannot be opened for reading.
     *
     * @exception IllegalArgumentException if any of the supplied access
     * level values differs from "readonly" or "readwrite".
     */
    public synchronized void refresh() throws IOException {
        Properties props;
        if (accessFileName == null)
            props = (Properties) originalProps;
        else
            props = propertiesFromFile(accessFileName);
        parseProperties(props);
    }

    private static Properties propertiesFromFile(String fname)
        throws IOException {
        FileInputStream fin = new FileInputStream(fname);
        try {
            Properties p = new Properties();
            p.load(fin);
	    // Properties.load does a buffered read so we don't need to wrap
	    // the FileInputStream in a BufferedInputStream.
            return p;
        } finally {
            fin.close();
        }
    }

    private synchronized void checkAccess(AccessType requiredAccess, String arg) {
        final AccessControlContext acc = AccessController.getContext();
        final Subject s =
            AccessController.doPrivileged(new PrivilegedAction<Subject>() {
                    public Subject run() {
                        return Subject.getSubject(acc);
                    }
                });
        if (s == null) return; /* security has not been enabled */
        final Set principals = s.getPrincipals();
        String newPropertyValue = null;
        for (Iterator i = principals.iterator(); i.hasNext(); ) {
            final Principal p = (Principal) i.next();
            Access access = accessMap.get(p.getName());
            if (access != null) {
                boolean ok;
                switch (requiredAccess) {
                    case READ:
                        ok = true;  // all access entries imply read
                        break;
                    case WRITE:
                        ok = access.write;
                        break;
                    case UNREGISTER:
                        ok = access.unregister;
                        if (!ok && access.write)
                            newPropertyValue = "unregister";
                        break;
                    case CREATE:
                        ok = checkCreateAccess(access, arg);
                        if (!ok && access.write)
                            newPropertyValue = "create " + arg;
                        break;
                    default:
                        throw new AssertionError();
                }
                if (ok)
                    return;
            }
        }
        SecurityException se = new SecurityException("Access denied! Invalid " +
                "access level for requested MBeanServer operation.");
        // Add some more information to help people with deployments that
        // worked before we required explicit create clauses. We're not giving
        // any information to the bad guys, other than that the access control
        // is based on a file, which they could have worked out from the stack
        // trace anyway.
        if (newPropertyValue != null) {
            SecurityException se2 = new SecurityException("Access property " +
                    "for this identity should be similar to: " + READWRITE +
                    " " + newPropertyValue);
            se.initCause(se2);
        }
        throw se;
    }

    private static boolean checkCreateAccess(Access access, String className) {
        for (String classNamePattern : access.createPatterns) {
            if (classNameMatch(classNamePattern, className))
                return true;
        }
        return false;
    }

    private static boolean classNameMatch(String pattern, String className) {
        // We studiously avoided regexes when parsing the properties file,
        // because that is done whenever the VM is started with the
        // appropriate -Dcom.sun.management options, even if nobody ever
        // creates an MBean.  We don't want to incur the overhead of loading
        // all the regex code whenever those options are specified, but if we
        // get as far as here then the VM is already running and somebody is
        // doing the very unusual operation of remotely creating an MBean.
        // Because that operation is so unusual, we don't try to optimize
        // by hand-matching or by caching compiled Pattern objects.
        StringBuilder sb = new StringBuilder();
        StringTokenizer stok = new StringTokenizer(pattern, "*", true);
        while (stok.hasMoreTokens()) {
            String tok = stok.nextToken();
            if (tok.equals("*"))
                sb.append("[^.]*");
            else
                sb.append(Pattern.quote(tok));
        }
        return className.matches(sb.toString());
    }

    private void parseProperties(Properties props) {
        this.accessMap = new HashMap<String, Access>();
        for (Map.Entry<Object, Object> entry : props.entrySet()) {
            String identity = (String) entry.getKey();
            String accessString = (String) entry.getValue();
            Access access = Parser.parseAccess(identity, accessString);
            accessMap.put(identity, access);
        }
    }

    private static class Parser {
        private final static int EOS = -1;  // pseudo-codepoint "end of string"
        static {
            assert !Character.isWhitespace(EOS);
        }

        private final String identity;  // just for better error messages
        private final String s;  // the string we're parsing
        private final int len;   // s.length()
        private int i;
        private int c;
        // At any point, either c is s.codePointAt(i), or i == len and
        // c is EOS.  We use int rather than char because it is conceivable
        // (if unlikely) that a classname in a create clause might contain
        // "supplementary characters", the ones that don't fit in the original
        // 16 bits for Unicode.

        private Parser(String identity, String s) {
            this.identity = identity;
            this.s = s;
            this.len = s.length();
            this.i = 0;
            if (i < len)
                this.c = s.codePointAt(i);
            else
                this.c = EOS;
        }

        static Access parseAccess(String identity, String s) {
            return new Parser(identity, s).parseAccess();
        }

        private Access parseAccess() {
            skipSpace();
            String type = parseWord();
            Access access;
            if (type.equals(READONLY))
                access = new Access(false, false, null);
            else if (type.equals(READWRITE))
                access = parseReadWrite();
            else {
                throw syntax("Expected " + READONLY + " or " + READWRITE +
                        ": " + type);
            }
            if (c != EOS)
                throw syntax("Extra text at end of line");
            return access;
        }

        private Access parseReadWrite() {
            List<String> createClasses = new ArrayList<String>();
            boolean unregister = false;
            while (true) {
                skipSpace();
                if (c == EOS)
                    break;
                String type = parseWord();
                if (type.equals(UNREGISTER))
                    unregister = true;
                else if (type.equals(CREATE))
                    parseCreate(createClasses);
                else
                    throw syntax("Unrecognized keyword " + type);
            }
            return new Access(true, unregister, createClasses);
        }

        private void parseCreate(List<String> createClasses) {
            while (true) {
                skipSpace();
                createClasses.add(parseClassName());
                skipSpace();
                if (c == ',')
                    next();
                else
                    break;
            }
        }

        private String parseClassName() {
            // We don't check that classname components begin with suitable
            // characters (so we accept 1.2.3 for example).  This means that
            // there are only two states, which we can call dotOK and !dotOK
            // according as a dot (.) is legal or not.  Initially we're in
            // !dotOK since a classname can't start with a dot; after a dot
            // we're in !dotOK again; and after any other characters we're in
            // dotOK.  The classname is only accepted if we end in dotOK,
            // so we reject an empty name or a name that ends with a dot.
            final int start = i;
            boolean dotOK = false;
            while (true) {
                if (c == '.') {
                    if (!dotOK)
                        throw syntax("Bad . in class name");
                    dotOK = false;
                } else if (c == '*' || Character.isJavaIdentifierPart(c))
                    dotOK = true;
                else
                    break;
                next();
            }
            String className = s.substring(start, i);
            if (!dotOK)
                throw syntax("Bad class name " + className);
            return className;
        }

        // Advance c and i to the next character, unless already at EOS.
        private void next() {
            if (c != EOS) {
                i += Character.charCount(c);
                if (i < len)
                    c = s.codePointAt(i);
                else
                    c = EOS;
            }
        }

        private void skipSpace() {
            while (Character.isWhitespace(c))
                next();
        }

        private String parseWord() {
            skipSpace();
            if (c == EOS)
                throw syntax("Expected word at end of line");
            final int start = i;
            while (c != EOS && !Character.isWhitespace(c))
                next();
            String word = s.substring(start, i);
            skipSpace();
            return word;
        }

        private IllegalArgumentException syntax(String msg) {
            return new IllegalArgumentException(
                    msg + " [" + identity + " " + s + "]");
        }
    }

    private Map<String, Access> accessMap;
    private Properties originalProps;
    private String accessFileName;
}
