content: /*
   * 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;
              }
          }
      }
  }
  