This is an automated email from the ASF dual-hosted git repository. frankgh pushed a commit to branch trunk in repository https://gitbox.apache.org/repos/asf/cassandra.git
The following commit(s) were added to refs/heads/trunk by this push: new c853efffa8 Support audit logging for JMX operations c853efffa8 is described below commit c853efffa8b173a3afe1b966456bb77db5a68883 Author: Abe Ratnofsky <a...@aber.io> AuthorDate: Fri Dec 6 13:41:31 2024 -0500 Support audit logging for JMX operations Patch by Abe Ratnofsky; reviewed by Bernardo Botella, Doug Rohrer, Francisco Guerrero for CASSANDRA-20128 --- CHANGES.txt | 1 + build.xml | 2 +- .../cassandra/audit/AuditLogEntryCategory.java | 2 +- .../apache/cassandra/audit/AuditLogEntryType.java | 3 +- .../org/apache/cassandra/audit/AuditLogFilter.java | 16 ++- .../apache/cassandra/audit/AuditLogManager.java | 131 ++++++++++++++++++- .../apache/cassandra/auth/CassandraPrincipal.java | 2 +- .../cassandra/auth/jmx/AuthorizationProxy.java | 56 ++++++--- .../org/apache/cassandra/utils/JMXServerUtils.java | 6 +- .../cassandra/utils/JmxInvocationListener.java | 50 ++++++++ .../apache/cassandra/audit/AuditLoggerTest.java | 138 ++++++++++++++++++++- .../cassandra/auth/jmx/AbstractJMXAuthTest.java | 34 +++++ 12 files changed, 411 insertions(+), 30 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index 797312bf01..cf59421428 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,4 +1,5 @@ 5.1 + * Support audit logging for JMX operations (CASSANDRA-20128) * Enable sorting of nodetool status output (CASSANDRA-20104) * Support downgrading after CMS is initialized (CASSANDRA-20145) * Deprecate IEndpointSnitch (CASSANDRA-19488) diff --git a/build.xml b/build.xml index 45af046260..ccc38717d2 100644 --- a/build.xml +++ b/build.xml @@ -338,7 +338,7 @@ <!-- needed to compile org.apache.cassandra.utils.JMXServerUtils --> <!-- needed to compile org.apache.cassandra.distributed.impl.Instance--> <!-- needed to compile org.apache.cassandra.utils.memory.BufferPool --> - <property name="jdk11plus-javac-exports" value="--add-exports java.rmi/sun.rmi.registry=ALL-UNNAMED --add-exports java.rmi/sun.rmi.transport.tcp=ALL-UNNAMED --add-exports java.base/jdk.internal.ref=ALL-UNNAMED --add-exports java.base/sun.nio.ch=ALL-UNNAMED" /> + <property name="jdk11plus-javac-exports" value="--add-exports java.rmi/sun.rmi.registry=ALL-UNNAMED --add-exports java.rmi/sun.rmi.transport.tcp=ALL-UNNAMED --add-exports java.base/jdk.internal.ref=ALL-UNNAMED --add-exports java.base/sun.nio.ch=ALL-UNNAMED --add-exports java.management/com.sun.jmx.remote.security=ALL-UNNAMED" /> <!-- Add all the dependencies. diff --git a/src/java/org/apache/cassandra/audit/AuditLogEntryCategory.java b/src/java/org/apache/cassandra/audit/AuditLogEntryCategory.java index 9db4ce05e9..b848440607 100644 --- a/src/java/org/apache/cassandra/audit/AuditLogEntryCategory.java +++ b/src/java/org/apache/cassandra/audit/AuditLogEntryCategory.java @@ -23,5 +23,5 @@ package org.apache.cassandra.audit; */ public enum AuditLogEntryCategory { - QUERY, DML, DDL, DCL, OTHER, AUTH, ERROR, PREPARE + QUERY, DML, DDL, DCL, OTHER, AUTH, ERROR, PREPARE, JMX } diff --git a/src/java/org/apache/cassandra/audit/AuditLogEntryType.java b/src/java/org/apache/cassandra/audit/AuditLogEntryType.java index 17d4c98fea..484895a21b 100644 --- a/src/java/org/apache/cassandra/audit/AuditLogEntryType.java +++ b/src/java/org/apache/cassandra/audit/AuditLogEntryType.java @@ -71,7 +71,8 @@ public enum AuditLogEntryType LOGIN_ERROR(AuditLogEntryCategory.AUTH), UNAUTHORIZED_ATTEMPT(AuditLogEntryCategory.AUTH), LOGIN_SUCCESS(AuditLogEntryCategory.AUTH), - LIST_SUPERUSERS(AuditLogEntryCategory.DCL); + LIST_SUPERUSERS(AuditLogEntryCategory.DCL), + JMX(AuditLogEntryCategory.JMX); private final AuditLogEntryCategory category; diff --git a/src/java/org/apache/cassandra/audit/AuditLogFilter.java b/src/java/org/apache/cassandra/audit/AuditLogFilter.java index d240e78c83..b775ac7785 100644 --- a/src/java/org/apache/cassandra/audit/AuditLogFilter.java +++ b/src/java/org/apache/cassandra/audit/AuditLogFilter.java @@ -127,7 +127,21 @@ final class AuditLogFilter } /** - * Checks whether a give AuditLog Entry is filtered or not + * Checks whether a given AuditLogEntryCategory is filtered or not. + * + * This is useful when creating an audit log entry might be expensive, and checking the category before formatting + * is less costly. + * + * @param category AuditLogEntryCategory to verify + * @return true if it is filtered, false otherwise + */ + boolean isFiltered(AuditLogEntryCategory category) + { + return isFiltered(category.toString(), includedCategories, excludedCategories); + } + + /** + * Checks whether a given AuditLog Entry is filtered or not * * @param auditLogEntry AuditLogEntry to verify * @return true if it is filtered, false otherwise diff --git a/src/java/org/apache/cassandra/audit/AuditLogManager.java b/src/java/org/apache/cassandra/audit/AuditLogManager.java index 0f49a54060..5d82bd9453 100644 --- a/src/java/org/apache/cassandra/audit/AuditLogManager.java +++ b/src/java/org/apache/cassandra/audit/AuditLogManager.java @@ -18,13 +18,26 @@ package org.apache.cassandra.audit; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; import java.nio.ByteBuffer; +import java.security.AccessControlContext; +import java.security.AccessController; +import java.security.Principal; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Objects; +import java.util.StringJoiner; import java.util.UUID; + import javax.annotation.Nullable; +import javax.management.MBeanServer; import javax.management.openmbean.CompositeData; +import javax.management.remote.MBeanServerForwarder; +import javax.security.auth.Subject; import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; @@ -49,15 +62,18 @@ import org.apache.cassandra.service.QueryState; import org.apache.cassandra.transport.Message; import org.apache.cassandra.transport.messages.ResultMessage; import org.apache.cassandra.utils.FBUtilities; +import org.apache.cassandra.utils.JmxInvocationListener; import org.apache.cassandra.utils.MBeanWrapper; import static org.apache.cassandra.utils.LocalizeString.toLowerCaseLocalized; +import static org.apache.cassandra.audit.AuditLogEntryType.JMX; + /** * Central location for managing the logging of client/user-initated actions (like queries, log in commands, and so on). * */ -public class AuditLogManager implements QueryEvents.Listener, AuthEvents.Listener, AuditLogManagerMBean +public class AuditLogManager implements QueryEvents.Listener, AuthEvents.Listener, AuditLogManagerMBean, JmxInvocationListener { private static final Logger logger = LoggerFactory.getLogger(AuditLogManager.class); @@ -69,6 +85,9 @@ public class AuditLogManager implements QueryEvents.Listener, AuthEvents.Listene private volatile AuditLogFilter filter; private volatile AuditLogOptions auditLogOptions; + // Only reset in tests + private MBeanServerForwarder mbsf = createMBeanServerForwarder(); + private AuditLogManager() { auditLogOptions = DatabaseDescriptor.getAuditLoggingOptions(); @@ -159,7 +178,7 @@ public class AuditLogManager implements QueryEvents.Listener, AuthEvents.Listene { builder.setType(AuditLogEntryType.LOGIN_ERROR); } - else + else if (logEntry.getType() != JMX) { builder.setType(AuditLogEntryType.REQUEST_FAILURE); } @@ -400,4 +419,112 @@ public class AuditLogManager implements QueryEvents.Listener, AuthEvents.Listene return PasswordObfuscator.obfuscate(e.getMessage()); } + + private static class JmxFormatter + { + private static String user(Subject subject) + { + if (subject == null) + return "null"; + StringJoiner joiner = new StringJoiner(", "); + for (Principal principal : subject.getPrincipals()) + joiner.add(Objects.toString(principal)); + return joiner.toString(); + } + + private static String method(Method method, Object[] args) + { + String argsFmt = ""; + if (args != null) + { + StringJoiner joiner = new StringJoiner(", "); + for (Object arg : args) + joiner.add(Objects.toString(arg)); + argsFmt = joiner.toString(); + } + return String.format("%s#%s(%s)", method.getDeclaringClass().getCanonicalName(), method.getName(), argsFmt); + } + } + + @Override + public void onInvocation(Subject subject, Method method, Object[] args) + { + if (filter.isFiltered(AuditLogEntryCategory.JMX)) + return; + + AuditLogEntry entry = new AuditLogEntry.Builder(JMX) + .setOperation(String.format("JMX INVOCATION: %s", JmxFormatter.method(method, args))) + .setUser(JmxFormatter.user(subject)) + .build(); + log(entry); + } + + @Override + public void onFailure(Subject subject, Method method, Object[] args, Exception exception) + { + if (filter.isFiltered(AuditLogEntryCategory.JMX)) + return; + + AuditLogEntry entry = new AuditLogEntry.Builder(JMX) + .setOperation(String.format("JMX FAILURE: %s due to %s", JmxFormatter.method(method, args), exception.getClass().getSimpleName())) + .setUser(JmxFormatter.user(subject)) + .build(); + log(entry, exception); + } + + private class JmxHandler implements InvocationHandler + { + private MBeanServer mbs = null; + + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable + { + // See AuthorizationProxy.invoke + if ("setMBeanServer".equals(method.getName())) + { + if (args[0] == null) + throw new IllegalArgumentException("Null MBeanServer"); + + if (mbs != null) + throw new IllegalArgumentException("MBeanServer already initialized"); + + mbs = (MBeanServer) args[0]; + return null; + } + + AccessControlContext acc = AccessController.getContext(); + Subject subject = Subject.getSubject(acc); + + try + { + Object invoke = method.invoke(mbs, args); + AuditLogManager.this.onInvocation(subject, method, args); + return invoke; + } + catch (InvocationTargetException e) + { + AuditLogManager.instance.onFailure(subject, method, args, e); + throw e.getCause(); + } + } + } + + private MBeanServerForwarder createMBeanServerForwarder() + { + InvocationHandler handler = new JmxHandler(); + Class<?>[] interfaces = { MBeanServerForwarder.class }; + Object proxy = Proxy.newProxyInstance(MBeanServerForwarder.class.getClassLoader(), interfaces, handler); + return (MBeanServerForwarder) proxy; + } + + @VisibleForTesting + void resetMBeanServerForwarder() + { + this.mbsf = createMBeanServerForwarder(); + } + + public MBeanServerForwarder getMBeanServerForwarder() + { + return mbsf; + } } diff --git a/src/java/org/apache/cassandra/auth/CassandraPrincipal.java b/src/java/org/apache/cassandra/auth/CassandraPrincipal.java index 41de802a27..8dd0f5a8c2 100644 --- a/src/java/org/apache/cassandra/auth/CassandraPrincipal.java +++ b/src/java/org/apache/cassandra/auth/CassandraPrincipal.java @@ -84,7 +84,7 @@ public class CassandraPrincipal implements Principal, Serializable @Override public String toString() { - return ("CassandraPrincipal: " + name); + return ("CassandraPrincipal: " + name); } /** diff --git a/src/java/org/apache/cassandra/auth/jmx/AuthorizationProxy.java b/src/java/org/apache/cassandra/auth/jmx/AuthorizationProxy.java index c6ea2d90a1..75576f6aef 100644 --- a/src/java/org/apache/cassandra/auth/jmx/AuthorizationProxy.java +++ b/src/java/org/apache/cassandra/auth/jmx/AuthorizationProxy.java @@ -39,9 +39,11 @@ import com.google.common.collect.ImmutableSet; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.apache.cassandra.audit.AuditLogManager; import org.apache.cassandra.auth.*; import org.apache.cassandra.config.DatabaseDescriptor; import org.apache.cassandra.service.StorageService; +import org.apache.cassandra.utils.JmxInvocationListener; import org.apache.cassandra.utils.MBeanWrapper; /** @@ -140,43 +142,57 @@ public class AuthorizationProxy implements InvocationHandler */ protected BooleanSupplier isAuthSetupComplete = () -> StorageService.instance.isAuthSetupComplete(); + protected JmxInvocationListener listener = AuditLogManager.instance; + @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { String methodName = method.getName(); - if ("getMBeanServer".equals(methodName)) - throw new SecurityException("Access denied"); - - // Corresponds to MBeanServer.invoke - if (methodName.equals("invoke") && args.length == 4) - checkVulnerableMethods(args); - // Retrieve Subject from current AccessControlContext AccessControlContext acc = AccessController.getContext(); Subject subject = Subject.getSubject(acc); - // Allow setMBeanServer iff performed on behalf of the connector server itself - if (("setMBeanServer").equals(methodName)) + try { - if (subject != null) + if ("getMBeanServer".equals(methodName)) throw new SecurityException("Access denied"); - if (args[0] == null) - throw new IllegalArgumentException("Null MBeanServer"); + // Corresponds to MBeanServer.invoke + if (methodName.equals("invoke") && args.length == 4) + checkVulnerableMethods(args); - if (mbs != null) - throw new IllegalArgumentException("MBeanServer already initialized"); + // Allow setMBeanServer iff performed on behalf of the connector server itself + if (("setMBeanServer").equals(methodName)) + { + if (subject != null) + throw new SecurityException("Access denied"); - mbs = (MBeanServer) args[0]; - return null; - } + if (args[0] == null) + throw new IllegalArgumentException("Null MBeanServer"); - if (authorize(subject, methodName, args)) - return invoke(method, args); + if (mbs != null) + throw new IllegalArgumentException("MBeanServer already initialized"); - throw new SecurityException("Access Denied"); + mbs = (MBeanServer) args[0]; + return null; + } + + if (authorize(subject, methodName, args)) + { + Object invoke = invoke(method, args); + listener.onInvocation(subject, method, args); + return invoke; + } + + throw new SecurityException("Access Denied"); + } + catch (Exception e) + { + listener.onFailure(subject, method, args, e); + throw e; + } } /** diff --git a/src/java/org/apache/cassandra/utils/JMXServerUtils.java b/src/java/org/apache/cassandra/utils/JMXServerUtils.java index be8f943d2b..0d61f2877d 100644 --- a/src/java/org/apache/cassandra/utils/JMXServerUtils.java +++ b/src/java/org/apache/cassandra/utils/JMXServerUtils.java @@ -53,6 +53,7 @@ import com.google.common.collect.ImmutableMap; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.apache.cassandra.audit.AuditLogManager; import org.apache.cassandra.auth.jmx.AuthenticationProxy; import org.apache.cassandra.config.CassandraRelevantProperties; import org.apache.cassandra.config.JMXServerOptions; @@ -135,6 +136,9 @@ public class JMXServerUtils // If a custom authz proxy was created, attach it to the server now. if (authzProxy != null) jmxServer.setMBeanServerForwarder(authzProxy); + else + jmxServer.setMBeanServerForwarder(AuditLogManager.instance.getMBeanServerForwarder()); + jmxServer.start(); registry.setRemoteServerStub(server.toStub()); @@ -274,7 +278,7 @@ public class JMXServerUtils } return String.format(urlTemplate, hostName, port); } - + private static class JMXPluggableAuthenticatorWrapper implements JMXAuthenticator { private static final MethodHandle ctorHandle; diff --git a/src/java/org/apache/cassandra/utils/JmxInvocationListener.java b/src/java/org/apache/cassandra/utils/JmxInvocationListener.java new file mode 100644 index 0000000000..cee473cc5f --- /dev/null +++ b/src/java/org/apache/cassandra/utils/JmxInvocationListener.java @@ -0,0 +1,50 @@ +/* + * 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.utils; + +import java.lang.reflect.Method; +import javax.security.auth.Subject; + +/** + * Listener for operations executed over JMX. + */ +public interface JmxInvocationListener +{ + /** + * Listener called when an attempted invocation is successful. + * + * @param subject The subject attempting invocation, depends on how JMX authentication is configured + * @param method Invoked method + * @param args Invoked method arguments + */ + default void onInvocation(Subject subject, Method method, Object[] args) + {} + + /** + * Listener called when an attempted invocation throws an exception. This could happen before or after the + * underlying method is invoked, due to invocation wrappers such as {@link org.apache.cassandra.auth.jmx.AuthorizationProxy#invoke(Object, Method, Object[])}. + * + * @param subject The subject attempting invocation, depends on how JMX authentication is configured + * @param method Invoked method + * @param args Invoked method arguments + * @param cause Exception thrown by the attempted invocation + */ + default void onFailure(Subject subject, Method method, Object[] args, Exception cause) + {} +} diff --git a/test/unit/org/apache/cassandra/audit/AuditLoggerTest.java b/test/unit/org/apache/cassandra/audit/AuditLoggerTest.java index 6cfaa52e64..46345d88fc 100644 --- a/test/unit/org/apache/cassandra/audit/AuditLoggerTest.java +++ b/test/unit/org/apache/cassandra/audit/AuditLoggerTest.java @@ -17,12 +17,24 @@ */ package org.apache.cassandra.audit; -import org.junit.After; import java.io.IOException; +import java.lang.reflect.Field; +import java.net.InetAddress; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Paths; +import java.rmi.server.RMISocketFactory; import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import javax.management.JMX; +import javax.management.MBeanServerConnection; +import javax.management.ObjectName; +import javax.management.remote.JMXConnector; +import javax.management.remote.JMXConnectorFactory; +import javax.management.remote.JMXServiceURL; +import org.junit.After; import org.junit.Assert; import org.junit.Before; import org.junit.BeforeClass; @@ -36,15 +48,35 @@ import com.datastax.driver.core.exceptions.NoHostAvailableException; import com.datastax.driver.core.exceptions.SyntaxError; import net.openhft.chronicle.queue.RollCycles; import org.apache.cassandra.auth.AuthEvents; +import org.apache.cassandra.auth.AuthenticatedUser; +import org.apache.cassandra.auth.IAuthorizer; +import org.apache.cassandra.auth.JMXResource; +import org.apache.cassandra.auth.Permission; +import org.apache.cassandra.auth.RoleResource; +import org.apache.cassandra.auth.StubAuthorizer; +import org.apache.cassandra.auth.jmx.AbstractJMXAuthTest; import org.apache.cassandra.config.DatabaseDescriptor; +import org.apache.cassandra.config.JMXServerOptions; import org.apache.cassandra.config.ParameterizedClass; import org.apache.cassandra.cql3.CQLTester; import org.apache.cassandra.cql3.QueryEvents; +import org.apache.cassandra.db.ColumnFamilyStoreMBean; import org.apache.cassandra.exceptions.ConfigurationException; import org.apache.cassandra.service.StorageService; - +import org.apache.cassandra.utils.JMXServerUtils; +import org.assertj.core.api.Assertions; + +import static org.apache.cassandra.config.CassandraRelevantProperties.CASSANDRA_JMX_AUTHORIZER; +import static org.apache.cassandra.config.CassandraRelevantProperties.CASSANDRA_JMX_LOCAL_PORT; +import static org.apache.cassandra.config.CassandraRelevantProperties.CASSANDRA_JMX_REMOTE_LOGIN_CONFIG; +import static org.apache.cassandra.config.CassandraRelevantProperties.COM_SUN_MANAGEMENT_JMXREMOTE_AUTHENTICATE; +import static org.apache.cassandra.config.CassandraRelevantProperties.JAVA_SECURITY_AUTH_LOGIN_CONFIG; import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.Matchers.emptyCollectionOf; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.stringContainsInOrder; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNull; @@ -758,6 +790,108 @@ public class AuditLoggerTest extends CQLTester assertEquals("/xyz/not/null", AuditLogManager.instance.getAuditLogOptions().archive_command); } + @Test + public void testJMXAuditLogs() throws Throwable + { + // Need to use distinct ports, otherwise would get RMI registry object ID collision, even with server shutdown between + testJMXAuditLogs(false, getAutomaticallyAllocatedPort(InetAddress.getLoopbackAddress())); + testJMXAuditLogs(true, getAutomaticallyAllocatedPort(InetAddress.getLoopbackAddress())); + } + + private void testJMXAuditLogs(boolean enableAuthorizationProxy, int port) throws Throwable + { + if (enableAuthorizationProxy) + { + // Set up JMX; see AbstractJMXAuthTest.setupAuthorizer + IAuthorizer authorizer = new StubAuthorizer(); + Field authorizerField = DatabaseDescriptor.class.getDeclaredField("authorizer"); + authorizerField.setAccessible(true); + authorizerField.set(null, authorizer); + DatabaseDescriptor.setPermissionsValidity(0); + } + + JMXResource tableMBean = JMXResource.mbean("org.apache.cassandra.db:type=Tables,keyspace=system_auth,table=roles"); + + if (enableAuthorizationProxy) + { + DatabaseDescriptor.getAuthorizer().grant(AuthenticatedUser.SYSTEM_USER, + Permission.ALL, + tableMBean, + RoleResource.role("test_role")); + } + + DatabaseDescriptor.setAuditLoggingOptions(new AuditLogOptions.Builder() + .withEnabled(true) + .withBlock(true) + .withLogger("InMemoryAuditLogger", null) + .build()); + + if (enableAuthorizationProxy) + { + String config = Paths.get(ClassLoader.getSystemResource("auth/cassandra-test-jaas.conf").toURI()).toString(); + COM_SUN_MANAGEMENT_JMXREMOTE_AUTHENTICATE.setBoolean(true); + JAVA_SECURITY_AUTH_LOGIN_CONFIG.setString(config); + CASSANDRA_JMX_REMOTE_LOGIN_CONFIG.setString("TestLogin"); + CASSANDRA_JMX_AUTHORIZER.setString(AbstractJMXAuthTest.NoSuperUserAuthorizationProxy.class.getName()); + } + CASSANDRA_JMX_LOCAL_PORT.setInt(port); + JMXServerOptions options = JMXServerOptions.createParsingSystemProperties(); + options.jmx_encryption_options.applyConfig(); + JMXServerUtils.createJMXServer(options, "localhost").start(); + + JMXServiceURL jmxUrl = new JMXServiceURL(String.format("service:jmx:rmi:///jndi/rmi://localhost:%d/jmxrmi", port)); + Map<String, Object> env = new HashMap<>(); + env.put("com.sun.jndi.rmi.factory.socket", RMISocketFactory.getDefaultSocketFactory()); + JMXConnector jmxc = JMXConnectorFactory.connect(jmxUrl, env); + MBeanServerConnection connection = jmxc.getMBeanServerConnection(); + + // Setting up the connection will cause a few JMX methods to be called, so need to reset to empty + Assert.assertThat(((InMemoryAuditLogger) AuditLogManager.instance.getLogger()).inMemQueue, not(emptyCollectionOf(AuditLogEntry.class))); + ((InMemoryAuditLogger) AuditLogManager.instance.getLogger()).inMemQueue.clear(); + + // Do an operation that doesn't fail + ColumnFamilyStoreMBean proxy = JMX.newMBeanProxy(connection, + ObjectName.getInstance(tableMBean.getObjectName()), + ColumnFamilyStoreMBean.class); + proxy.getTableName(); + AuditLogEntry logEntry = ((InMemoryAuditLogger) AuditLogManager.instance.getLogger()).inMemQueue.poll(); + assertEquals(AuditLogEntryType.JMX, logEntry.getType()); + assertThat(logEntry.getOperation(), containsString("JMX INVOCATION")); + if (enableAuthorizationProxy) + assertThat(logEntry.getUser(), is("CassandraPrincipal: test_role")); + else + assertThat(logEntry.getUser(), is("null")); + assertEquals(0, ((InMemoryAuditLogger) AuditLogManager.instance.getLogger()).inMemQueue.size()); + + // Do an operation that fails + tableMBean = JMXResource.mbean("org.apache.cassandra.db:type=Tables,keyspace=system_auth,table=roles"); + proxy = JMX.newMBeanProxy(connection, + ObjectName.getInstance(tableMBean.getObjectName()), + ColumnFamilyStoreMBean.class); + + ColumnFamilyStoreMBean finalProxy = proxy; + Assertions.assertThatThrownBy(() -> finalProxy.setMinimumCompactionThreshold(Integer.MAX_VALUE)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("min_compaction_threshold cannot be larger than the max_compaction_threshold"); + + logEntry = ((InMemoryAuditLogger) AuditLogManager.instance.getLogger()).inMemQueue.poll(); + assertEquals(AuditLogEntryType.JMX, logEntry.getType()); + assertThat(logEntry.getOperation(), stringContainsInOrder("JMX INVOCATION", "getClassLoaderFor")); + assertThat(logEntry.getUser(), containsString("null")); + + // 2 JMX calls are produced, one to search for class, then one to invoke + logEntry = ((InMemoryAuditLogger) AuditLogManager.instance.getLogger()).inMemQueue.poll(); + assertEquals(AuditLogEntryType.JMX, logEntry.getType()); + assertThat(logEntry.getOperation(), stringContainsInOrder("JMX FAILURE", "setAttribute")); + if (enableAuthorizationProxy) + assertThat(logEntry.getUser(), is("CassandraPrincipal: test_role")); + else + assertThat(logEntry.getUser(), is("null")); + Assertions.assertThat(((InMemoryAuditLogger) AuditLogManager.instance.getLogger()).inMemQueue).isEmpty(); + + AuditLogManager.instance.resetMBeanServerForwarder(); + } + /** * Helper methods for Audit Log CQL Testing */ diff --git a/test/unit/org/apache/cassandra/auth/jmx/AbstractJMXAuthTest.java b/test/unit/org/apache/cassandra/auth/jmx/AbstractJMXAuthTest.java index 0d75ce6a62..625795c10a 100644 --- a/test/unit/org/apache/cassandra/auth/jmx/AbstractJMXAuthTest.java +++ b/test/unit/org/apache/cassandra/auth/jmx/AbstractJMXAuthTest.java @@ -22,6 +22,7 @@ import java.lang.reflect.Field; import java.rmi.server.RMISocketFactory; import java.util.HashMap; import java.util.Map; +import java.util.Queue; import javax.management.JMX; import javax.management.MBeanServerConnection; import javax.management.ObjectName; @@ -39,6 +40,11 @@ import org.junit.Before; import org.junit.Ignore; import org.junit.Test; +import org.apache.cassandra.audit.AuditLogEntry; +import org.apache.cassandra.audit.AuditLogEntryType; +import org.apache.cassandra.audit.AuditLogManager; +import org.apache.cassandra.audit.AuditLogOptions; +import org.apache.cassandra.audit.InMemoryAuditLogger; import org.apache.cassandra.auth.AuthenticatedUser; import org.apache.cassandra.auth.CassandraPrincipal; import org.apache.cassandra.auth.IAuthorizer; @@ -51,6 +57,7 @@ import org.apache.cassandra.config.JMXServerOptions; import org.apache.cassandra.cql3.CQLTester; import org.apache.cassandra.db.ColumnFamilyStoreMBean; import org.apache.cassandra.utils.JMXServerUtils; +import org.assertj.core.api.Assertions; import static org.junit.Assert.assertEquals; import static org.junit.Assert.fail; @@ -64,6 +71,7 @@ public abstract class AbstractJMXAuthTest extends CQLTester private RoleResource role; private String tableName; private JMXResource tableMBean; + private Queue<AuditLogEntry> auditLogs; @Before public void setup() throws Throwable @@ -73,6 +81,8 @@ public abstract class AbstractJMXAuthTest extends CQLTester tableName = createTable("CREATE TABLE %s (k int, v int, PRIMARY KEY (k))"); tableMBean = JMXResource.mbean(String.format("org.apache.cassandra.db:type=Tables,keyspace=%s,table=%s", KEYSPACE, tableName)); + AuditLogManager.instance.enable(DatabaseDescriptor.getAuditLoggingOptions()); + auditLogs = ((InMemoryAuditLogger) AuditLogManager.instance.getLogger()).internalQueue(); } @Test @@ -157,6 +167,12 @@ public abstract class AbstractJMXAuthTest extends CQLTester protected static void setupJMXServer(JMXServerOptions jmxServerOptions) throws Exception { + DatabaseDescriptor.setAuditLoggingOptions(new AuditLogOptions.Builder() + .withEnabled(true) + .withBlock(true) + .withLogger("InMemoryAuditLogger", null) + .build()); + jmxServerOptions.jmx_encryption_options.applyConfig(); jmxServer = JMXServerUtils.createJMXServer(jmxServerOptions, "localhost"); jmxServer.start(); @@ -210,6 +226,10 @@ public abstract class AbstractJMXAuthTest extends CQLTester private void assertAuthorized(MBeanAction action) { action.execute(); + AuditLogEntry entry = nextAuditEvent(auditLogs); + Assertions.assertThat(entry.getType()).isSameAs(AuditLogEntryType.JMX); + Assertions.assertThat(entry.getUser()).contains(role.getRoleName()); + Assertions.assertThat(entry.getOperation()).contains("JMX INVOCATION"); } private void assertUnauthorized(MBeanAction action) @@ -222,9 +242,23 @@ public abstract class AbstractJMXAuthTest extends CQLTester catch (SecurityException e) { assertEquals("Access Denied", e.getLocalizedMessage()); + AuditLogEntry entry = nextAuditEvent(auditLogs); + Assertions.assertThat(entry.getType()).isSameAs(AuditLogEntryType.JMX); + Assertions.assertThat(entry.getUser()).contains(role.getRoleName()); + Assertions.assertThat(entry.getOperation()).contains("JMX FAILURE"); + Assertions.assertThat(entry.getOperation()).contains(e.getLocalizedMessage()); } } + private static AuditLogEntry nextAuditEvent(Queue<AuditLogEntry> auditLogs) + { + // When creating a new proxy, classloaders are loaded over JMX, so ignore those for permission assertions + AuditLogEntry next = auditLogs.remove(); + if (next.getOperation().startsWith("JMX INVOCATION: javax.management.MBeanServer#getClassLoaderFor")) + return auditLogs.remove(); + return next; + } + private void clearAllPermissions() { ((StubAuthorizer) DatabaseDescriptor.getAuthorizer()).clear(); --------------------------------------------------------------------- To unsubscribe, e-mail: commits-unsubscr...@cassandra.apache.org For additional commands, e-mail: commits-h...@cassandra.apache.org