/*
 * 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.cassandra.auth.jmx;

import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.BooleanSupplier;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import javax.management.MalformedObjectNameException;
import javax.management.ObjectName;
import javax.security.auth.Subject;

import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import org.junit.BeforeClass;
import org.junit.Test;

import org.apache.cassandra.auth.*;
import org.apache.cassandra.config.DatabaseDescriptor;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;

public class AuthorizationProxyTest
{
    @BeforeClass
    public static void setup() throws Exception
    {
        DatabaseDescriptor.daemonInitialization();
    }

    JMXResource osBean = JMXResource.mbean("java.lang:type=OperatingSystem");
    JMXResource runtimeBean = JMXResource.mbean("java.lang:type=Runtime");
    JMXResource threadingBean = JMXResource.mbean("java.lang:type=Threading");
    JMXResource javaLangWildcard = JMXResource.mbean("java.lang:type=*");

    JMXResource hintsBean = JMXResource.mbean("org.apache.cassandra.hints:type=HintsService");
    JMXResource batchlogBean = JMXResource.mbean("org.apache.cassandra.db:type=BatchlogManager");
    JMXResource customBean = JMXResource.mbean("org.apache.cassandra:type=CustomBean,property=foo");
    Set<ObjectName> allBeans = objectNames(osBean, runtimeBean, threadingBean, hintsBean, batchlogBean, customBean);

    RoleResource role1 = RoleResource.role("r1");

    @Test
    public void roleHasRequiredPermission() throws Throwable
    {
        Map<RoleResource, Set<PermissionDetails>> permissions =
            ImmutableMap.of(role1, Collections.singleton(permission(role1, osBean, Permission.SELECT)));

        AuthorizationProxy proxy = new ProxyBuilder().isSuperuser((role) -> false)
                                                     .getPermissions(permissions::get)
                                                     .isAuthzRequired(() -> true)
                                                     .build();

        assertTrue(proxy.authorize(subject(role1.getRoleName()),
                                   "getAttribute",
                                   new Object[]{ objectName(osBean), "arch" }));
    }

    @Test
    public void roleDoesNotHaveRequiredPermission() throws Throwable
    {
        Map<RoleResource, Set<PermissionDetails>> permissions =
            ImmutableMap.of(role1, Collections.singleton(permission(role1, osBean, Permission.AUTHORIZE)));

        AuthorizationProxy proxy = new ProxyBuilder().isSuperuser((role) -> false)
                                                     .getPermissions(permissions::get)
                                                     .isAuthzRequired(() -> true).build();

        assertFalse(proxy.authorize(subject(role1.getRoleName()),
                                    "setAttribute",
                                    new Object[]{ objectName(osBean), "arch" }));
    }

    @Test
    public void roleHasRequiredPermissionOnRootResource() throws Throwable
    {
        Map<RoleResource, Set<PermissionDetails>> permissions =
            ImmutableMap.of(role1, Collections.singleton(permission(role1, JMXResource.root(), Permission.SELECT)));

        AuthorizationProxy proxy = new ProxyBuilder().isSuperuser((role) -> false)
                                                     .getPermissions(permissions::get)
                                                     .isAuthzRequired(() -> true)
                                                     .build();

        assertTrue(proxy.authorize(subject(role1.getRoleName()),
                                   "getAttribute",
                                   new Object[]{ objectName(osBean), "arch" }));
    }

    @Test
    public void roleHasOtherPermissionOnRootResource() throws Throwable
    {
        Map<RoleResource, Set<PermissionDetails>> permissions =
            ImmutableMap.of(role1, Collections.singleton(permission(role1, JMXResource.root(), Permission.AUTHORIZE)));

        AuthorizationProxy proxy = new ProxyBuilder().isSuperuser((role) -> false)
                                                     .getPermissions(permissions::get)
                                                     .isAuthzRequired(() -> true)
                                                     .build();

        assertFalse(proxy.authorize(subject(role1.getRoleName()),
                                    "invoke",
                                    new Object[]{ objectName(osBean), "bogusMethod" }));
    }

    @Test
    public void roleHasNoPermissions() throws Throwable
    {
        AuthorizationProxy proxy = new ProxyBuilder().isSuperuser((role) -> false)
                                                     .getPermissions((role) -> Collections.emptySet())
                                                     .isAuthzRequired(() -> true)
                                                     .build();

        assertFalse(proxy.authorize(subject(role1.getRoleName()),
                                    "getAttribute",
                                    new Object[]{ objectName(osBean), "arch" }));
    }

    @Test
    public void roleHasNoPermissionsButIsSuperuser() throws Throwable
    {
        AuthorizationProxy proxy = new ProxyBuilder().isSuperuser((role) -> true)
                                                     .getPermissions((role) -> Collections.emptySet())
                                                     .isAuthzRequired(() -> true)
                                                     .build();

        assertTrue(proxy.authorize(subject(role1.getRoleName()),
                                   "getAttribute",
                                   new Object[]{ objectName(osBean), "arch" }));
    }

    @Test
    public void roleHasNoPermissionsButAuthzNotRequired() throws Throwable
    {
        AuthorizationProxy proxy = new ProxyBuilder().isSuperuser((role) -> false)
                                                     .getPermissions((role) -> Collections.emptySet())
                                                     .isAuthzRequired(() -> false)
                                                     .build();

        assertTrue(proxy.authorize(subject(role1.getRoleName()),
                                   "getAttribute",
                                   new Object[]{ objectName(osBean), "arch" }));
    }

    @Test
    public void authorizeWhenSubjectIsNull() throws Throwable
    {
        // a null subject indicates that the action is being performed by the
        // connector itself, so we always authorize it
        // Verify that the superuser status is never tested as the request returns early
        // due to the null Subject
        // Also, hardcode the permissions provider to return an empty set, so we know that
        // can be doubly sure that it's the null Subject which causes the authz to succeed
        final AtomicBoolean suStatusChecked = new AtomicBoolean(false);
        AuthorizationProxy proxy = new ProxyBuilder().getPermissions((role) -> Collections.emptySet())
                                                     .isAuthzRequired(() -> true)
                                                     .isSuperuser((role) ->
                                                                  {
                                                                      suStatusChecked.set(true);
                                                                      return false;
                                                                  })
                                                     .build();

        assertTrue(proxy.authorize(null,
                                   "getAttribute",
                                   new Object[]{ objectName(osBean), "arch" }));
        assertFalse(suStatusChecked.get());
    }

    @Test
    public void rejectWhenSubjectNotAuthenticated() throws Throwable
    {
        // Access is denied to a Subject without any associated Principals
        // Verify that the superuser status is never tested as the request is rejected early
        // due to the Subject
        final AtomicBoolean suStatusChecked = new AtomicBoolean(false);
        AuthorizationProxy proxy = new ProxyBuilder().isAuthzRequired(() -> true)
                                                     .isSuperuser((role) ->
                                                                  {
                                                                      suStatusChecked.set(true);
                                                                      return true;
                                                                  })
                                                     .build();
        assertFalse(proxy.authorize(new Subject(),
                                    "getAttribute",
                                    new Object[]{ objectName(osBean), "arch" }));
        assertFalse(suStatusChecked.get());
    }

    @Test
    public void authorizeWhenWildcardGrantCoversExactTarget() throws Throwable
    {
        Map<RoleResource, Set<PermissionDetails>> permissions =
            ImmutableMap.of(role1, Collections.singleton(permission(role1, javaLangWildcard, Permission.SELECT)));

        AuthorizationProxy proxy = new ProxyBuilder().isAuthzRequired(() -> true)
                                                     .isSuperuser((role) -> false)
                                                     .getPermissions(permissions::get)
                                                     .build();

        assertTrue(proxy.authorize(subject(role1.getRoleName()),
                                   "getAttribute",
                                   new Object[]{ objectName(osBean), "arch" }));
    }

    @Test
    public void rejectWhenWildcardGrantDoesNotCoverExactTarget() throws Throwable
    {
        Map<RoleResource, Set<PermissionDetails>> permissions =
            ImmutableMap.of(role1, Collections.singleton(permission(role1, javaLangWildcard, Permission.SELECT)));

        AuthorizationProxy proxy = new ProxyBuilder().isAuthzRequired(() -> true)
                                                     .isSuperuser((role) -> false)
                                                     .getPermissions(permissions::get)
                                                     .build();

        assertFalse(proxy.authorize(subject(role1.getRoleName()),
                                    "getAttribute",
                                    new Object[]{ objectName(customBean), "arch" }));
    }

    @Test
    public void authorizeWhenWildcardGrantCoversWildcardTarget() throws Throwable
    {
        Map<RoleResource, Set<PermissionDetails>> permissions =
            ImmutableMap.of(role1, Collections.singleton(permission(role1, javaLangWildcard, Permission.DESCRIBE)));

        AuthorizationProxy proxy = new ProxyBuilder().isAuthzRequired(() -> true)
                                                     .isSuperuser((role) -> false)
                                                     .getPermissions(permissions::get)
                                                     .queryNames(matcher(allBeans))
                                                     .build();

        assertTrue(proxy.authorize(subject(role1.getRoleName()),
                                   "queryNames",
                                   new Object[]{ objectName(javaLangWildcard), null }));
    }

    @Test
    public void rejectWhenWildcardGrantIsDisjointWithWildcardTarget() throws Throwable
    {
        JMXResource customWildcard = JMXResource.mbean("org.apache.cassandra:*");
        Map<RoleResource, Set<PermissionDetails>> permissions =
            ImmutableMap.of(role1, Collections.singleton(permission(role1, customWildcard, Permission.DESCRIBE)));

        AuthorizationProxy proxy = new ProxyBuilder().isAuthzRequired(() -> true)
                                                     .isSuperuser((role) -> false)
                                                     .getPermissions(permissions::get)
                                                     .queryNames(matcher(allBeans))
                                                     .build();

        // the grant on org.apache.cassandra:* shouldn't permit us to invoke queryNames with java.lang:*
        assertFalse(proxy.authorize(subject(role1.getRoleName()),
                                    "queryNames",
                                    new Object[]{ objectName(javaLangWildcard), null }));
    }

    @Test
    public void rejectWhenWildcardGrantIntersectsWithWildcardTarget() throws Throwable
    {
        // in this test, permissions are granted on org.apache.cassandra:type=CustomBean,property=*
        // and all beans in the org.apache.cassandra.hints domain, but
        // but the target of the invocation is org.apache.cassandra*:*
        // i.e. the subject has permissions on all CustomBeans and on the HintsService bean, but is
        // attempting to query all names in the org.apache.cassandra* domain. The operation should
        // be rejected as the permissions don't cover all known beans matching that domain, due to
        // the BatchLogManager bean.

        JMXResource allCustomBeans = JMXResource.mbean("org.apache.cassandra:type=CustomBean,property=*");
        JMXResource allHintsBeans = JMXResource.mbean("org.apache.cassandra.hints:*");
        ObjectName allCassandraBeans = ObjectName.getInstance("org.apache.cassandra*:*");

        Map<RoleResource, Set<PermissionDetails>> permissions =
            ImmutableMap.of(role1, ImmutableSet.of(permission(role1, allCustomBeans, Permission.DESCRIBE),
                                                   permission(role1, allHintsBeans, Permission.DESCRIBE)));

        AuthorizationProxy proxy = new ProxyBuilder().isAuthzRequired(() -> true)
                                                     .isSuperuser((role) -> false)
                                                     .getPermissions(permissions::get)
                                                     .queryNames(matcher(allBeans))
                                                     .build();

        // the grant on org.apache.cassandra:* shouldn't permit us to invoke queryNames with java.lang:*
        assertFalse(proxy.authorize(subject(role1.getRoleName()),
                                    "queryNames",
                                    new Object[]{ allCassandraBeans, null }));
    }

    @Test
    public void authorizeOnTargetWildcardWithPermissionOnRoot() throws Throwable
    {
        Map<RoleResource, Set<PermissionDetails>> permissions =
            ImmutableMap.of(role1, Collections.singleton(permission(role1, JMXResource.root(), Permission.SELECT)));

        AuthorizationProxy proxy = new ProxyBuilder().isAuthzRequired(() -> true)
                                                     .isSuperuser((role) -> false)
                                                     .getPermissions(permissions::get)
                                                     .build();

        assertTrue(proxy.authorize(subject(role1.getRoleName()),
                                   "getAttribute",
                                   new Object[]{ objectName(javaLangWildcard), "arch" }));
    }

    @Test
    public void rejectInvocationOfUnknownMethod() throws Throwable
    {
        // Grant ALL permissions on the root resource, so we know that it's
        // the unknown method that causes the authz rejection. Of course, this
        // isn't foolproof but it's something.
        Set<PermissionDetails> allPerms = Permission.ALL.stream()
                                                        .map(perm -> permission(role1, JMXResource.root(), perm))
                                                        .collect(Collectors.toSet());
        Map<RoleResource, Set<PermissionDetails>> permissions = ImmutableMap.of(role1, allPerms);
        AuthorizationProxy proxy = new ProxyBuilder().isAuthzRequired(() -> true)
                                                     .isSuperuser((role) -> false)
                                                     .getPermissions(permissions::get)
                                                     .build();

        assertFalse(proxy.authorize(subject(role1.getRoleName()),
                                    "unKnownMethod",
                                    new Object[] { ObjectName.getInstance(osBean.getObjectName()) }));
    }

    @Test
    public void rejectInvocationOfRestrictedMethods() throws Throwable
    {
        String[] methods = { "createMBean",
                             "deserialize",
                             "getClassLoader",
                             "getClassLoaderFor",
                             "instantiate",
                             "registerMBean",
                             "unregisterMBean" };

        // Hardcode the superuser status check to return true, so any allowed method can be invoked.
        AuthorizationProxy proxy = new ProxyBuilder().isAuthzRequired(() -> true)
                                                     .isSuperuser((role) -> true)
                                                     .build();

        for (String method : methods)
            // the arguments array isn't significant, so it can just be empty
            assertFalse(proxy.authorize(subject(role1.getRoleName()), method, new Object[0]));
    }

    @Test
    public void authorizeMethodsWithoutMBeanArgumentIfPermissionsGranted() throws Throwable
    {
        // Certain methods on MBeanServer don't take an ObjectName as their first argument.
        // These methods are characterised by AuthorizationProxy as being concerned with
        // the MBeanServer itself, as opposed to a specific managed bean. Of these methods,
        // only those considered "descriptive" are allowed to be invoked by remote users.
        // These require the DESCRIBE permission on the root JMXResource.
        testNonMbeanMethods(true);
    }

    @Test
    public void rejectMethodsWithoutMBeanArgumentIfPermissionsNotGranted() throws Throwable
    {
        testNonMbeanMethods(false);
    }

    @Test
    public void rejectWhenAuthSetupIsNotComplete() throws Throwable
    {
        // IAuthorizer & IRoleManager should not be considered ready to use until
        // we know that auth setup has completed. So, even though the IAuthorizer
        // would theoretically grant access, the auth proxy should deny it if setup
        // hasn't finished.

        Map<RoleResource, Set<PermissionDetails>> permissions =
        ImmutableMap.of(role1, Collections.singleton(permission(role1, osBean, Permission.SELECT)));

        // verify that access is granted when setup is complete
        AuthorizationProxy proxy = new ProxyBuilder().isSuperuser((role) -> false)
                                                     .getPermissions(permissions::get)
                                                     .isAuthzRequired(() -> true)
                                                     .isAuthSetupComplete(() -> true)
                                                     .build();

        assertTrue(proxy.authorize(subject(role1.getRoleName()),
                                   "getAttribute",
                                   new Object[]{ objectName(osBean), "arch" }));

        // and denied when it isn't
        proxy = new ProxyBuilder().isSuperuser((role) -> false)
                                  .getPermissions(permissions::get)
                                  .isAuthzRequired(() -> true)
                                  .isAuthSetupComplete(() -> false)
                                  .build();

        assertFalse(proxy.authorize(subject(role1.getRoleName()),
                                   "getAttribute",
                                   new Object[]{ objectName(osBean), "arch" }));
    }

    private void testNonMbeanMethods(boolean withPermission)
    {
        String[] methods = { "getDefaultDomain",
                             "getDomains",
                             "getMBeanCount",
                             "hashCode",
                             "queryMBeans",
                             "queryNames",
                             "toString" };


        ProxyBuilder builder = new ProxyBuilder().isAuthzRequired(() -> true).isSuperuser((role) -> false);
        if (withPermission)
        {
            Map<RoleResource, Set<PermissionDetails>> permissions =
                ImmutableMap.of(role1, ImmutableSet.of(permission(role1, JMXResource.root(), Permission.DESCRIBE)));
            builder.getPermissions(permissions::get);
        }
        else
        {
            builder.getPermissions((role) -> Collections.emptySet());
        }
        AuthorizationProxy proxy = builder.build();

        for (String method : methods)
            assertEquals(withPermission, proxy.authorize(subject(role1.getRoleName()), method, new Object[]{ null }));

        // non-allowed methods should be rejected regardless.
        // This isn't exactly comprehensive, but it's better than nothing
        String[] notAllowed = { "fooMethod", "barMethod", "bazMethod" };
        for (String method : notAllowed)
            assertFalse(proxy.authorize(subject(role1.getRoleName()), method, new Object[]{ null }));
    }

    // provides a simple matching function which can be substituted for the proxy's queryMBeans
    // utility (which by default just delegates to the MBeanServer)
    // This function just iterates over a supplied set of ObjectNames and filters out those
    // to which the target name *doesn't* apply
    private static Function<ObjectName, Set<ObjectName>> matcher(Set<ObjectName> allBeans)
    {
        return (target) -> allBeans.stream()
                                   .filter(target::apply)
                                   .collect(Collectors.toSet());
    }

    private static PermissionDetails permission(RoleResource grantee, IResource resource, Permission permission)
    {
        return new PermissionDetails(grantee.getRoleName(), resource, permission);
    }

    private static Subject subject(String roleName)
    {
        Subject subject = new Subject();
        subject.getPrincipals().add(new CassandraPrincipal(roleName));
        return subject;
    }

    private static ObjectName objectName(JMXResource resource) throws MalformedObjectNameException
    {
        return ObjectName.getInstance(resource.getObjectName());
    }

    private static Set<ObjectName> objectNames(JMXResource... resource)
    {
        Set<ObjectName> names = new HashSet<>();
        try
        {
            for (JMXResource r : resource)
                names.add(objectName(r));
        }
        catch (MalformedObjectNameException e)
        {
            fail("JMXResource returned invalid object name: " + e.getMessage());
        }
        return names;
    }

    public static class ProxyBuilder
    {
        Function<RoleResource, Set<PermissionDetails>> getPermissions;
        Function<ObjectName, Set<ObjectName>> queryNames;
        Predicate<RoleResource> isSuperuser;
        BooleanSupplier isAuthzRequired;
        BooleanSupplier isAuthSetupComplete = () -> true;

        AuthorizationProxy build()
        {
            InjectableAuthProxy proxy = new InjectableAuthProxy();

            if (getPermissions != null)
                proxy.setGetPermissions(getPermissions);

            if (queryNames != null)
                proxy.setQueryNames(queryNames);

            if (isSuperuser != null)
                proxy.setIsSuperuser(isSuperuser);

            if (isAuthzRequired != null)
                proxy.setIsAuthzRequired(isAuthzRequired);

            proxy.setIsAuthSetupComplete(isAuthSetupComplete);

            return proxy;
        }

        ProxyBuilder getPermissions(Function<RoleResource, Set<PermissionDetails>> f)
        {
            getPermissions = f;
            return this;
        }

        ProxyBuilder queryNames(Function<ObjectName, Set<ObjectName>> f)
        {
            queryNames = f;
            return this;
        }

        ProxyBuilder isSuperuser(Predicate<RoleResource> f)
        {
            isSuperuser = f;
            return this;
        }

        ProxyBuilder isAuthzRequired(BooleanSupplier s)
        {
            isAuthzRequired = s;
            return this;
        }

        ProxyBuilder isAuthSetupComplete(BooleanSupplier s)
        {
            isAuthSetupComplete = s;
            return this;
        }

        private static class InjectableAuthProxy extends AuthorizationProxy
        {
            void setGetPermissions(Function<RoleResource, Set<PermissionDetails>> f)
            {
                this.getPermissions = f;
            }

            void setQueryNames(Function<ObjectName, Set<ObjectName>> f)
            {
                this.queryNames = f;
            }

            void setIsSuperuser(Predicate<RoleResource> f)
            {
                this.isSuperuser = f;
            }

            void setIsAuthzRequired(BooleanSupplier s)
            {
                this.isAuthzRequired = s;
            }

            void setIsAuthSetupComplete(BooleanSupplier s)
            {
                this.isAuthSetupComplete = s;
            }
        }
    }
}
