diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/management/ArtemisMBeanServerGuard.java b/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/management/ArtemisMBeanServerGuard.java index f7cd7c3dc6d..1a24f282382 100644 --- a/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/management/ArtemisMBeanServerGuard.java +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/management/ArtemisMBeanServerGuard.java @@ -36,6 +36,11 @@ import java.lang.reflect.Method; import java.security.Principal; import java.util.List; +import java.util.Map; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; public class ArtemisMBeanServerGuard implements GuardInvocationHandler { @@ -43,6 +48,18 @@ public class ArtemisMBeanServerGuard implements GuardInvocationHandler { private JMXAccessControlList jmxAccessControlList = JMXAccessControlList.createDefaultList(); + private final Map bypassRBACCache = new ConcurrentHashMap<>(); + + private static final class CachedRolesPrincipal implements Principal { + final Set roles; + + CachedRolesPrincipal(Set roles) { + this.roles = Collections.unmodifiableSet(roles); + } + + @Override public String getName() { return "__cached_roles__"; } + } + public void init() { ArtemisMBeanServerBuilder.setGuard(this); } @@ -122,18 +139,12 @@ private void handleSetAttributes(MBeanServer proxy, ObjectName objectName, Attri } private boolean canBypassRBAC(ObjectName objectName) { - return jmxAccessControlList.isInAllowList(objectName); + return bypassRBACCache.computeIfAbsent(objectName, name -> jmxAccessControlList.isInAllowList(name)); } @Override public boolean canInvoke(String object, String operationName) { - ObjectName objectName = null; - try { - objectName = ObjectName.getInstance(object); - } catch (MalformedObjectNameException e) { - logger.debug("can't check invoke rights as object name invalid: {}", object, e); - return false; - } + /* * HawtIO calls this with a null operationName as a coarse grained way of authenticating against all the * operations on an mbean. Until this addition this was throwing a null pointer on operationName later in this @@ -142,7 +153,19 @@ public boolean canInvoke(String object, String operationName) { * it. Since it is just an optimisation it is fine to always return true. Note that the alternative * ArtemisRbacInvocationHandler does allow the ability to restrict a whole mbean. */ - if (operationName == null || canBypassRBAC(objectName)) { + if (operationName == null) { + return true; + } + + ObjectName objectName = null; + try { + objectName = ObjectName.getInstance(object); + } catch (MalformedObjectNameException e) { + logger.debug("can't check invoke rights as object name invalid: {}", object, e); + return false; + } + + if (canBypassRBAC(objectName)) { return true; } @@ -151,15 +174,21 @@ public boolean canInvoke(String object, String operationName) { if (paramListIndex > 0) { operationName = operationName.substring(0, paramListIndex); } + Set currentUserRoles = getCurrentUserRoles(); - List requiredRoles = getRequiredRoles(objectName, operationName); - for (String role : requiredRoles) { - if (currentUserHasRole(role)) { - return true; - } + if (currentUserRoles.isEmpty()) { + return false; } - logger.debug("{} {} false", object, operationName); - return false; + boolean authorized = authorizeUserForMethod(objectName, operationName, currentUserRoles); + + if (authorized) { + logger.debug("{} {} true", object, operationName); + return true; + } else { + logger.debug("{} {} false", object, operationName); + return false; + } + } void handleInvoke(ObjectName objectName, String operationName) throws IOException { @@ -182,6 +211,10 @@ List getRequiredRoles(ObjectName objectName, String methodName) { return jmxAccessControlList.getRolesForObject(objectName, methodName); } + boolean authorizeUserForMethod(ObjectName objectName, String operationName, Set currentUserRoles) { + return jmxAccessControlList.authorizeUserForMethod(objectName, operationName, currentUserRoles); + } + public void setJMXAccessControlList(JMXAccessControlList JMXAccessControlList) { this.jmxAccessControlList = JMXAccessControlList; } @@ -210,4 +243,26 @@ public static boolean currentUserHasRole(String requestedRole) { } return false; } + + public static Set getCurrentUserRoles() { + Subject subject = SecurityManagerShim.currentSubject(); + if (subject == null) { + return Collections.emptySet(); + } + + // Check if roles are already cached on the subject + Set cached = subject.getPrincipals(CachedRolesPrincipal.class); + if (!cached.isEmpty()) { + return cached.iterator().next().roles; + } + + // First call for this subject — build and cache + Set roles = new HashSet<>(); + for (Principal p : subject.getPrincipals()) { + roles.add(p.getName()); + } + subject.getPrincipals().add(new CachedRolesPrincipal(roles)); + return roles; + } + } diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/management/JMXAccessControlList.java b/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/management/JMXAccessControlList.java index bb6d18d444e..4b52889fd61 100644 --- a/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/management/JMXAccessControlList.java +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/management/JMXAccessControlList.java @@ -18,11 +18,13 @@ import javax.management.ObjectName; import java.util.ArrayList; +import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.TreeMap; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; @@ -31,6 +33,36 @@ public class JMXAccessControlList { private static final String WILDCARD = "*"; + private record AccessEntry(Access access, String rawPattern) { } + private record Bucket( + Map exactMatches, + List regexPatterns + ) { } + + private final Map> keyPropertyCache = + Collections.synchronizedMap(new LinkedHashMap>(128, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry> eldest) { + return size() > 5000; + } + }); + + private final Map> domainCache = + Collections.synchronizedMap(new LinkedHashMap>(128, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry> eldest) { + return size() > 5000; + } + }); + + private final Map> bucketedDomainCache = + Collections.synchronizedMap(new LinkedHashMap<>(128, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry> eldest) { + return size() > 1000; + } + }); + private Access defaultAccess = new Access(WILDCARD); private ConcurrentMap> domainAccess = new ConcurrentHashMap<>(); private ConcurrentMap> allowList = new ConcurrentHashMap<>(); @@ -48,6 +80,7 @@ public class JMXAccessControlList { return key2.length() - key1.length(); }; + public void addToAllowList(String domain, String key) { TreeMap domainMap = new TreeMap<>(keyComparator); domainMap = allowList.putIfAbsent(domain, domainMap); @@ -81,6 +114,86 @@ public List getRolesForObject(ObjectName objectName, String methodName) return defaultAccess.getMatchingRolesForMethod(methodName); } + + public boolean authorizeUserForMethod(ObjectName objectName, String methodName, Set userRoles) { + + String domainKey = objectName.getDomain(); + + TreeMap domainMap = domainCache.computeIfAbsent(objectName.getDomain(), key -> + domainAccess.get(key) + ); + + Map bucketedMap = bucketedDomainCache.computeIfAbsent(domainKey, d -> { + TreeMap rawMap = domainAccess.get(d); + if (rawMap == null) { + return null; + } + + Map grouped = new HashMap<>(); + for (Access access : rawMap.values()) { + String rawPattern = access.getKeyPattern().pattern(); + int eqIndex = rawPattern.indexOf('='); + String prefix = (eqIndex != -1) ? rawPattern.substring(0, eqIndex) : ""; + + // Initialize the Bucket (Map + List) instead of just an ArrayList + Bucket bucket = grouped.computeIfAbsent(prefix, k -> + new Bucket(new HashMap<>(), new ArrayList<>()) + ); + + AccessEntry entry = new AccessEntry(access, rawPattern); + + // Sort into Exact or Regex + if (rawPattern.contains("*") || rawPattern.contains("?") || rawPattern.contains("[")) { + bucket.regexPatterns().add(entry); + } else { + bucket.exactMatches().put(rawPattern, entry); + } + } + return grouped; + }); + + if (bucketedMap != null) { + + String cacheKey = objectName.getCanonicalName(); + Map keyPropertyList = keyPropertyCache.get(cacheKey); + if (keyPropertyList == null) { + keyPropertyList = objectName.getKeyPropertyList(); + keyPropertyCache.put(cacheKey, keyPropertyList); + } + + + for (Map.Entry entry : keyPropertyList.entrySet()) { + String propKey = entry.getKey(); + Bucket bucket = bucketedMap.get(propKey); + + if (bucket != null) { + String normalizedValue = normalizeKey(propKey + "=" + entry.getValue()); + + // Priority 1: O(1) Exact Match Check + if (bucket.exactMatches().containsKey(normalizedValue)) { + return bucket.exactMatches().get(normalizedValue).access().authorizeUserForMethod(methodName, userRoles); + } + + // Priority 2: O(N) Regex Match (but only for actual regexes) + for (AccessEntry regexEntry : bucket.regexPatterns()) { + if (regexEntry.access().getKeyPattern().matcher(normalizedValue).matches()) { + return regexEntry.access().authorizeUserForMethod(methodName, userRoles); + } + } + } + } + + Access access = domainMap.get(""); + if (access != null) { + return access.authorizeUserForMethod(methodName, userRoles); + } + } + + return defaultAccess.authorizeUserForMethod(methodName, userRoles); + } + + + public boolean isInAllowList(ObjectName objectName) { TreeMap domainMap = allowList.get(objectName.getDomain()); @@ -223,6 +336,20 @@ public List getMatchingRolesForMethod(String methodName) { } return catchAllRoles; } + + public boolean authorizeUserForMethod(String methodName, Set userRoles) { + List roles = methodRoles.get(methodName); + if (roles != null) { + return !Collections.disjoint(roles, userRoles); + + } + for (Map.Entry> entry : methodPrefixRoles.entrySet()) { + if (methodName.startsWith(entry.getKey())) { + return !Collections.disjoint(entry.getValue(), userRoles); + } + } + return !Collections.disjoint(catchAllRoles, userRoles); + } } public static JMXAccessControlList createDefaultList() { diff --git a/artemis-server/src/test/java/org/apache/activemq/artemis/core/server/management/ArtemisMBeanServerGuardTest.java b/artemis-server/src/test/java/org/apache/activemq/artemis/core/server/management/ArtemisMBeanServerGuardTest.java index dba3dea0378..9a9b6d6bdc3 100644 --- a/artemis-server/src/test/java/org/apache/activemq/artemis/core/server/management/ArtemisMBeanServerGuardTest.java +++ b/artemis-server/src/test/java/org/apache/activemq/artemis/core/server/management/ArtemisMBeanServerGuardTest.java @@ -82,7 +82,7 @@ public void testCanInvokeMethodHasRole() throws Throwable { @Test - public void testCanInvokeMethodDoeNotHasRole() throws Throwable { + public void testCanInvokeMethodDoesNotHaveRole() throws Throwable { ArtemisMBeanServerGuard guard = new ArtemisMBeanServerGuard(); JMXAccessControlList controlList = new JMXAccessControlList(); guard.setJMXAccessControlList(controlList); diff --git a/tests/performance-jmh/src/main/java/org/apache/activemq/artemis/tests/performance/jmh/MBeanServerGuardCanInvokePerfTest.java b/tests/performance-jmh/src/main/java/org/apache/activemq/artemis/tests/performance/jmh/MBeanServerGuardCanInvokePerfTest.java new file mode 100644 index 00000000000..a60886213e5 --- /dev/null +++ b/tests/performance-jmh/src/main/java/org/apache/activemq/artemis/tests/performance/jmh/MBeanServerGuardCanInvokePerfTest.java @@ -0,0 +1,314 @@ +/* + * 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.activemq.artemis.tests.performance.jmh; + +import org.apache.activemq.artemis.core.server.management.ArtemisMBeanServerGuard; +import org.apache.activemq.artemis.core.server.management.JMXAccessControlList; +import org.apache.activemq.artemis.spi.core.security.jaas.RolePrincipal; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; + +import javax.management.MalformedObjectNameException; +import javax.management.ObjectName; +import javax.security.auth.Subject; +import java.security.PrivilegedAction; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; + +@State(Scope.Benchmark) +@Fork(2) +@Warmup(iterations = 5, time = 1) +@Measurement(iterations = 8, time = 1) +@BenchmarkMode(Mode.Throughput) +public class MBeanServerGuardCanInvokePerfTest { + + private ArtemisMBeanServerGuard guard; + private String[] objectNames; + private String[] operationNames; + private String[] operationNamesWithParams; + private String allowListedObjectName; + private Subject subjectWithViewRole; + private Subject subjectWithAmqRole; + private Subject subjectWithNoRole; + + // 1000 fixed queue object names for the console-pattern benchmarks — + // not randomized, scanned sequentially to exercise the cache. + private static final int CONSOLE_QUEUE_COUNT = 1000; + private String[] consoleObjectNames; // String form for canInvoke() + private ObjectName[] consoleObjectInstances; // ObjectName form for authorizeUserForMethod() + + // Pre-built role set equivalent to what getCurrentUserRoles() would produce + // for subjectWithViewRole — avoids the HashSet allocation inside canInvoke() + // so authorizeUserForMethod() can be benchmarked in isolation. + private Set prebuiltViewRoleSet; + private Set prebuiltAmqRoleSet; + + @Param({"10", "50", "100"}) + int objectNameCount; + + @Setup + public void init() throws MalformedObjectNameException { + guard = new ArtemisMBeanServerGuard(); + JMXAccessControlList acl = JMXAccessControlList.createDefaultList(); + guard.setJMXAccessControlList(acl); + + // --- original random object names (kept for existing benchmarks) ------- + objectNames = new String[objectNameCount]; + for (int i = 0; i < objectNameCount; i++) { + if (i % 3 == 0) { + objectNames[i] = "org.apache.activemq.artemis:broker=\"0.0.0.0\",component=addresses,address=\"address." + i + "\""; + } else if (i % 3 == 1) { + objectNames[i] = "org.apache.activemq.artemis:broker=\"0.0.0.0\",component=addresses,subcomponent=queues,routing-type=ANYCAST,name=\"queue-" + i + "\""; + } else { + objectNames[i] = "java.lang:type=Memory,name=HeapMemoryUsage-" + i; + } + } + + allowListedObjectName = "hawtio:type=security,name=RBACRegistry"; + + operationNames = new String[]{ + "getMessageCount", + "listMessages", + "sendMessage", + "removeMessage", + "getConsumerCount", + "listConsumers", + "getAttribute", + "setAttribute", + "invoke" + }; + + operationNamesWithParams = new String[]{ + "sendMessage(java.lang.String)", + "sendMessage(java.lang.String,java.lang.String)", + "removeMessage(long)", + "listMessages(java.lang.String)", + "setAttribute(java.lang.String,java.lang.Object)" + }; + + subjectWithViewRole = new Subject(); + subjectWithViewRole.getPrincipals().add(new RolePrincipal("view")); + + subjectWithAmqRole = new Subject(); + subjectWithAmqRole.getPrincipals().add(new RolePrincipal("amq")); + + subjectWithNoRole = new Subject(); + + // --- 1000 fixed queue object names (console-pattern benchmarks) -------- + // These mirror what the web console loads: one ObjectName per queue on a + // broker with many queues. Fixed order, never randomized — the cache + // warms on the first scan and subsequent scans measure the cached path. + consoleObjectNames = new String[CONSOLE_QUEUE_COUNT]; + consoleObjectInstances = new ObjectName[CONSOLE_QUEUE_COUNT]; + for (int i = 0; i < CONSOLE_QUEUE_COUNT; i++) { + consoleObjectNames[i] = + "org.apache.activemq.artemis:broker=\"0.0.0.0\",component=addresses," + + "subcomponent=queues,routing-type=ANYCAST,name=\"queue-" + i + "\""; + consoleObjectInstances[i] = ObjectName.getInstance(consoleObjectNames[i]); + } + + // Pre-built role sets — mirrors what getCurrentUserRoles() builds from the + // subject's principals. One set per subject type used in the benchmarks. + prebuiltViewRoleSet = new HashSet<>(); + for (java.security.Principal p : subjectWithViewRole.getPrincipals()) { + prebuiltViewRoleSet.add(p.getName()); + } + + prebuiltAmqRoleSet = new HashSet<>(); + for (java.security.Principal p : subjectWithAmqRole.getPrincipals()) { + prebuiltAmqRoleSet.add(p.getName()); + } + } + + @Benchmark + public boolean testCanInvokeAllowListed() { + return guard.canInvoke(allowListedObjectName, "anyOperation"); + } + + @Benchmark + public boolean testCanInvokeWithViewRole() { + return Subject.doAs(subjectWithViewRole, (PrivilegedAction) () -> { + int idx = ThreadLocalRandom.current().nextInt(objectNames.length); + int opIdx = ThreadLocalRandom.current().nextInt(operationNames.length); + return guard.canInvoke(objectNames[idx], operationNames[opIdx]); + }); + } + + @Benchmark + public boolean testCanInvokeWithAmqRole() { + return Subject.doAs(subjectWithAmqRole, (PrivilegedAction) () -> { + int idx = ThreadLocalRandom.current().nextInt(objectNames.length); + int opIdx = ThreadLocalRandom.current().nextInt(operationNames.length); + return guard.canInvoke(objectNames[idx], operationNames[opIdx]); + }); + } + + @Benchmark + public boolean testCanInvokeWithNoRole() { + return Subject.doAs(subjectWithNoRole, (PrivilegedAction) () -> { + int idx = ThreadLocalRandom.current().nextInt(objectNames.length); + int opIdx = ThreadLocalRandom.current().nextInt(operationNames.length); + return guard.canInvoke(objectNames[idx], operationNames[opIdx]); + }); + } + + @Benchmark + public boolean testCanInvokeWithParameterStripping() { + return Subject.doAs(subjectWithViewRole, (PrivilegedAction) () -> { + int idx = ThreadLocalRandom.current().nextInt(objectNames.length); + int opIdx = ThreadLocalRandom.current().nextInt(operationNamesWithParams.length); + return guard.canInvoke(objectNames[idx], operationNamesWithParams[opIdx]); + }); + } + + @Benchmark + public boolean testCanInvokeNullOperation() { + return guard.canInvoke(objectNames[0], null); + } + + @Benchmark + public boolean testCanInvokeInvalidObjectName() { + return guard.canInvoke("invalid:object:name:with:too:many:colons", "operation"); + } + + @Benchmark + public boolean testCanInvokeMixedScenarios() { + return Subject.doAs(subjectWithViewRole, (PrivilegedAction) () -> { + int scenario = ThreadLocalRandom.current().nextInt(4); + switch (scenario) { + case 0: + return guard.canInvoke(allowListedObjectName, "operation"); + case 1: + int idx = ThreadLocalRandom.current().nextInt(objectNames.length); + int opIdx = ThreadLocalRandom.current().nextInt(operationNames.length); + return guard.canInvoke(objectNames[idx], operationNames[opIdx]); + case 2: + idx = ThreadLocalRandom.current().nextInt(objectNames.length); + opIdx = ThreadLocalRandom.current().nextInt(operationNamesWithParams.length); + return guard.canInvoke(objectNames[idx], operationNamesWithParams[opIdx]); + default: + return guard.canInvoke(objectNames[0], null); + } + }); + } + + @Benchmark + public boolean testAuthorizeUserForMethodWithViewRole() { + return Subject.doAs(subjectWithViewRole, (PrivilegedAction) () -> { + int idx = ThreadLocalRandom.current().nextInt(objectNames.length); + int opIdx = ThreadLocalRandom.current().nextInt(operationNames.length); + return guard.authorizeUserForMethod(objectNames[idx], "getMessageCount", prebuiltViewRoleSet); + }); + } + + // ------------------------------------------------------------------------- + // Console-pattern benchmarks — sequential scan, cache exercises + // ------------------------------------------------------------------------- + + /** + * Simulates a web console page load: one user checks getMessageCount on all + * 1000 queues in order. The cache warms on the first JMH iteration; all + * subsequent iterations measure the cached path. + * + * Use AverageTime to measure full-scan latency (how long the console waits). + * This is where the 20x improvement should be visible. + */ + @Benchmark + @BenchmarkMode(Mode.AverageTime) + @OutputTimeUnit(TimeUnit.MICROSECONDS) + public void testConsolePageLoadViewRole() { + Subject.doAs(subjectWithViewRole, (PrivilegedAction) () -> { + for (int i = 0; i < CONSOLE_QUEUE_COUNT; i++) { + guard.canInvoke(consoleObjectNames[i], "getMessageCount"); + } + return null; + }); + } + + @Benchmark + @BenchmarkMode(Mode.AverageTime) + @OutputTimeUnit(TimeUnit.MICROSECONDS) + public void testConsolePageLoadAmqRole() { + Subject.doAs(subjectWithAmqRole, (PrivilegedAction) () -> { + for (int i = 0; i < CONSOLE_QUEUE_COUNT; i++) { + guard.canInvoke(consoleObjectNames[i], "getMessageCount"); + } + return null; + }); + } + + /** + * Same console scan but with no role — exercises the early-exit path + * (getCurrentUserRoles() returns empty set) across 1000 queues. + */ + @Benchmark + @BenchmarkMode(Mode.AverageTime) + @OutputTimeUnit(TimeUnit.MICROSECONDS) + public void testConsolePageLoadNoRole() { + Subject.doAs(subjectWithNoRole, (PrivilegedAction) () -> { + for (int i = 0; i < CONSOLE_QUEUE_COUNT; i++) { + guard.canInvoke(consoleObjectNames[i], "getMessageCount"); + } + return null; + }); + } + + // ------------------------------------------------------------------------- + // authorizeUserForMethod in isolation — pre-built role set, no allocation + // ------------------------------------------------------------------------- + + /** + * Single call with a random ObjectName — equivalent to the existing canInvoke + * benchmarks but with the getCurrentUserRoles() HashSet allocation removed. + * Isolates the cost of the method lookup and cache inside authorizeUserForMethod. + */ + @Benchmark + public boolean testAuthorizeUserForMethodRandom() { + int idx = ThreadLocalRandom.current().nextInt(objectNameCount); + return guard.authorizeUserForMethod( + consoleObjectInstances[idx], "getMessageCount", prebuiltViewRoleSet); + } + + /** + * Sequential scan through all 1000 ObjectName instances with a pre-built + * role set. Directly measures the authorizeUserForMethod cache: first scan + * populates it, subsequent scans hit it. + * + * Comparing this to testConsolePageLoadViewRole shows how much of the + * canInvoke cost is getCurrentUserRoles() allocation vs the actual lookup. + */ + @Benchmark + @BenchmarkMode(Mode.AverageTime) + @OutputTimeUnit(TimeUnit.MICROSECONDS) + public void testAuthorizeUserForMethodConsolePattern() { + for (int i = 0; i < CONSOLE_QUEUE_COUNT; i++) { + guard.authorizeUserForMethod( + consoleObjectInstances[i], "getMessageCount", prebuiltViewRoleSet); + } + } +} diff --git a/tests/performance-jmh/src/main/java/org/apache/activemq/artemis/tests/performance/jmh/MBeanServerGuardCanInvokePerfTest2.java b/tests/performance-jmh/src/main/java/org/apache/activemq/artemis/tests/performance/jmh/MBeanServerGuardCanInvokePerfTest2.java new file mode 100644 index 00000000000..620be40931b --- /dev/null +++ b/tests/performance-jmh/src/main/java/org/apache/activemq/artemis/tests/performance/jmh/MBeanServerGuardCanInvokePerfTest2.java @@ -0,0 +1,151 @@ +/* + * 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.activemq.artemis.tests.performance.jmh; + +import org.apache.activemq.artemis.core.server.management.ArtemisMBeanServerGuard; +import org.apache.activemq.artemis.core.server.management.JMXAccessControlList; +import org.apache.activemq.artemis.spi.core.security.jaas.RolePrincipal; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; + +import javax.security.auth.Subject; +import java.security.PrivilegedAction; +import java.util.concurrent.TimeUnit; + +/** + * Simulates a web console page load under realistic scale: + * - 1000 queues + * - 5 operations checked per queue (5000 canInvoke calls per iteration) + * - 1000 roles defined in the ACL + * - each user belongs to 10 groups, only the last one matches an ACL role + * (worst-case scan through disjoint() and principal list) + * + * Three user types: + * view — last principal matches role-999 (authorized, worst-case match position) + * amq — first principal matches role-0 (authorized, best-case match position) + * none — no principals match any ACL role (always denied) + */ +@State(Scope.Benchmark) +@Fork(2) +@Warmup(iterations = 5, time = 1) +@Measurement(iterations = 8, time = 1) +@BenchmarkMode(Mode.Throughput) +@OutputTimeUnit(TimeUnit.SECONDS) +public class MBeanServerGuardCanInvokePerfTest { + + private static final int QUEUE_COUNT = 1000; + private static final int ACL_ROLE_COUNT = 1000; + private static final int USER_ROLE_COUNT = 10; + + private static final String[] OPERATIONS = { + "sendMessage(java.lang.String)", + "sendMessage(java.lang.String,java.lang.String)", + "removeMessage(long)", + "listMessages(java.lang.String)", + "setAttribute(java.lang.String,java.lang.Object)" + }; + + private ArtemisMBeanServerGuard guard; + private String[] queueObjectNames; + private Subject subjectWithViewRole; // last principal matches (worst case) + private Subject subjectWithAmqRole; // first principal matches (best case) + private Subject subjectWithNoRole; // no match (always denied) + + @Setup + public void init() throws Exception { + guard = new ArtemisMBeanServerGuard(); + + // ACL: 1000 roles, each authorised for all operations on the artemis domain + JMXAccessControlList acl = new JMXAccessControlList(); + for (int i = 0; i < ACL_ROLE_COUNT; i++) { + acl.addToRoleAccess("org.apache.activemq.artemis", null, "*", "role-" + i); + } + guard.setJMXAccessControlList(acl); + + // 1000 queue object names, fixed order, never randomized + queueObjectNames = new String[QUEUE_COUNT]; + for (int i = 0; i < QUEUE_COUNT; i++) { + queueObjectNames[i] = + "org.apache.activemq.artemis:broker=\"0.0.0.0\",component=addresses," + + "subcomponent=queues,routing-type=ANYCAST,name=\"queue-" + i + "\""; + } + + // view: 9 non-matching groups + role-999 last — worst-case disjoint() scan + subjectWithViewRole = new Subject(); + for (int i = 0; i < USER_ROLE_COUNT - 1; i++) { + subjectWithViewRole.getPrincipals().add(new RolePrincipal("user-group-" + i)); + } + subjectWithViewRole.getPrincipals().add(new RolePrincipal("role-999")); + + // amq: role-0 first — best-case match, hits immediately + subjectWithAmqRole = new Subject(); + subjectWithAmqRole.getPrincipals().add(new RolePrincipal("role-0")); + for (int i = 1; i < USER_ROLE_COUNT; i++) { + subjectWithAmqRole.getPrincipals().add(new RolePrincipal("user-group-" + i)); + } + + // none: all 10 principals are outside the ACL — full miss every time + subjectWithNoRole = new Subject(); + for (int i = 0; i < USER_ROLE_COUNT; i++) { + subjectWithNoRole.getPrincipals().add(new RolePrincipal("no-match-" + i)); + } + } + + @Benchmark + public void testConsolePageLoadViewRole() { + Subject.doAs(subjectWithViewRole, (PrivilegedAction) () -> { + for (int i = 0; i < QUEUE_COUNT; i++) { + for (String op : OPERATIONS) { + guard.canInvoke(queueObjectNames[i], op); + } + } + return null; + }); + } + + @Benchmark + public void testConsolePageLoadAmqRole() { + Subject.doAs(subjectWithAmqRole, (PrivilegedAction) () -> { + for (int i = 0; i < QUEUE_COUNT; i++) { + for (String op : OPERATIONS) { + guard.canInvoke(queueObjectNames[i], op); + } + } + return null; + }); + } + + @Benchmark + public void testConsolePageLoadNoRole() { + Subject.doAs(subjectWithNoRole, (PrivilegedAction) () -> { + for (int i = 0; i < QUEUE_COUNT; i++) { + for (String op : OPERATIONS) { + guard.canInvoke(queueObjectNames[i], op); + } + } + return null; + }); + } +}