diff --git a/src/integrationTest/java/org/opensearch/security/privileges/actionlevel/RoleBasedActionPrivilegesTest.java b/src/integrationTest/java/org/opensearch/security/privileges/actionlevel/RoleBasedActionPrivilegesTest.java
index 1fc0ef35c6..df0a95948d 100644
--- a/src/integrationTest/java/org/opensearch/security/privileges/actionlevel/RoleBasedActionPrivilegesTest.java
+++ b/src/integrationTest/java/org/opensearch/security/privileges/actionlevel/RoleBasedActionPrivilegesTest.java
@@ -24,6 +24,7 @@
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
@@ -37,12 +38,15 @@
import org.opensearch.common.settings.Settings;
import org.opensearch.core.common.unit.ByteSizeUnit;
import org.opensearch.core.common.unit.ByteSizeValue;
+import org.opensearch.security.DefaultObjectMapper;
import org.opensearch.security.privileges.PrivilegesEvaluationContext;
import org.opensearch.security.privileges.PrivilegesEvaluatorResponse;
import org.opensearch.security.resolver.IndexResolverReplacer;
+import org.opensearch.security.securityconf.DynamicConfigFactory;
import org.opensearch.security.securityconf.FlattenedActionGroups;
import org.opensearch.security.securityconf.impl.CType;
import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration;
+import org.opensearch.security.securityconf.impl.v7.ActionGroupsV7;
import org.opensearch.security.securityconf.impl.v7.RoleV7;
import org.opensearch.security.user.User;
import org.opensearch.security.util.MockIndexMetadataBuilder;
@@ -279,6 +283,118 @@ public void hasAny_wildcard() throws Exception {
);
}
+ /**
+ * Tests that ClusterPrivileges construction is optimized when many roles share the same permission patterns.
+ * The pattern cache should ensure each unique pattern is only processed once.
+ *
+ * This test simulates a realistic scenario with:
+ * - 1000 roles, each with a unique index pattern + shared patterns
+ * - DLS queries using user attribute substitution (${attr.internal.should_hide})
+ * - 100 actual indices to test against
+ */
+ @Test
+ public void constructionPerformance_sharedPatterns() throws Exception {
+ // Shared cluster permission patterns - 3 are wildcards that should be cached
+ List sharedClusterPatterns = Arrays.asList(
+ "cluster:admin/tasks/cancel",
+ "cluster:monitor/task",
+ "cluster:monitor/task/get",
+ "indices:admin/aliases/get",
+ "indices:admin/exists",
+ "indices:admin/get",
+ "indices:admin/mappings/get",
+ "indices:admin/mapping/put",
+ "indices:admin/refresh*", // wildcard pattern 1
+ "indices:data*", // wildcard pattern 2
+ "indices:admin/flush*", // wildcard pattern 3
+ "indices:admin/forcemerge",
+ "indices:monitor/stats"
+ );
+
+ // Shared index patterns that all roles have
+ List sharedIndexPatterns = Arrays.asList("logs-*", "metrics-*");
+
+ // DLS query with user attribute substitution
+ String dlsQuery = "{\"bool\": {\"must\": {\"match\": {\"should_hide\": \"${attr.internal.should_hide}\"}}}}";
+
+ // Create 1000 roles, each with:
+ // - shared cluster permissions
+ // - unique index pattern (role_N_index_*) + shared index patterns
+ // - DLS query with user attribute substitution
+ // - First 100 roles also have alias-* pattern to test alias expansion
+ Map rolesMap = new HashMap<>();
+ for (int i = 0; i < 1000; i++) {
+ List indexPatterns = new ArrayList<>(sharedIndexPatterns);
+ indexPatterns.add("role_" + i + "_index_*"); // unique pattern per role
+ if (i < 100) {
+ indexPatterns.add("alias-*"); // test alias expansion for first 100 roles
+ }
+
+ Map indexPermission = new HashMap<>();
+ indexPermission.put("index_patterns", indexPatterns);
+ indexPermission.put("dls", dlsQuery);
+ indexPermission.put("allowed_actions", Arrays.asList("indices_all"));
+
+ rolesMap.put(
+ "role_" + i,
+ ImmutableMap.of("cluster_permissions", sharedClusterPatterns, "index_permissions", Arrays.asList(indexPermission))
+ );
+ }
+
+ SecurityDynamicConfiguration roles = SecurityDynamicConfiguration.fromMap(rolesMap, CType.ROLES);
+
+ // Create 100 actual indices and 1000 aliases
+ String[] indexNames = new String[10000];
+ for (int i = 0; i < 10000; i++) {
+ indexNames[i] = "logs-" + i;
+ }
+ MockIndexMetadataBuilder builder = indices(indexNames);
+ // Create 1000 aliases, each pointing to a subset of indices
+ for (int i = 0; i < 5000; i++) {
+ builder.alias("alias-" + i).of("logs-" + i);
+ }
+ Metadata indexMetadata = builder.build();
+
+ // Load static action groups (includes indices_all -> indices:*)
+ JsonNode staticActionGroupsJsonNode = DefaultObjectMapper.YAML_MAPPER.readTree(
+ DynamicConfigFactory.class.getResourceAsStream("/static_config/static_action_groups.yml")
+ );
+ SecurityDynamicConfiguration actionGroupsConfig = SecurityDynamicConfiguration.fromNode(
+ staticActionGroupsJsonNode,
+ CType.ACTIONGROUPS,
+ 2,
+ 0,
+ 0
+ );
+ FlattenedActionGroups actionGroups = new FlattenedActionGroups(actionGroupsConfig);
+
+ // Build RoleBasedActionPrivileges and measure construction time
+ long start = System.nanoTime();
+ RoleBasedActionPrivileges subject = new RoleBasedActionPrivileges(roles, actionGroups, Settings.EMPTY);
+ long constructionMs = java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);
+
+ long startStateful = System.nanoTime();
+ subject.updateStatefulIndexPrivileges(indexMetadata.getIndicesLookup(), 1);
+ long statefulMs = java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startStateful);
+
+ System.out.println("[constructionPerformance_sharedPatterns] RoleBasedActionPrivileges construction: " + constructionMs + "ms");
+ System.out.println("[constructionPerformance_sharedPatterns] StatefulIndexPrivileges update: " + statefulMs + "ms");
+ System.out.println("[constructionPerformance_sharedPatterns] Total: " + (constructionMs + statefulMs) + "ms");
+
+ // Verify correctness - cluster privileges
+ assertThat(subject.hasClusterPrivilege(ctx().roles("role_0").get(), "cluster:monitor/task/get"), isAllowed());
+ assertThat(subject.hasClusterPrivilege(ctx().roles("role_999").get(), "cluster:monitor/task/get"), isAllowed());
+ assertThat(subject.hasClusterPrivilege(ctx().roles("role_500").get(), "indices:data/read/search"), isAllowed());
+
+ // Verify correctness - index privileges with user attributes
+ PrivilegesEvaluatorResponse result = subject.hasIndexPrivilege(
+ ctx().roles("role_0").attr("attr.internal.should_hide", "false").indexMetadata(indexMetadata).get(),
+ ImmutableSet.of("indices:data/read/search"),
+ IndexResolverReplacer.Resolved.ofIndex("logs-0")
+ );
+ assertThat(result, isAllowed());
+ }
+
}
/**