Explorar el Código

Reduce verbosity of the bulk indexing audit log (#98470)

This PR reduces the number of audit entries for bulk items
in bulk shard requests. Currently, each bulk item from every
bulk shard request generates exactly one audit log entry.
This change reduces that to one audit log per action type
(out of 4 possible) per bulk shard request.
Albert Zaharovits hace 2 años
padre
commit
293650c458

+ 5 - 0
docs/changelog/98470.yaml

@@ -0,0 +1,5 @@
+pr: 98470
+summary: Reduce verbosity of the bulk indexing audit log
+area: Audit
+type: enhancement
+issues: []

+ 2 - 1
server/src/main/java/org/elasticsearch/action/bulk/BulkItemRequest.java

@@ -60,7 +60,8 @@ public class BulkItemRequest implements Writeable, Accountable {
         return request.indices()[0];
         return request.indices()[0];
     }
     }
 
 
-    BulkItemResponse getPrimaryResponse() {
+    // public for tests
+    public BulkItemResponse getPrimaryResponse() {
         return primaryResponse;
         return primaryResponse;
     }
     }
 
 

+ 1 - 1
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/audit/AuditTrail.java

@@ -104,7 +104,7 @@ public interface AuditTrail {
         AuditLevel eventType,
         AuditLevel eventType,
         Authentication authentication,
         Authentication authentication,
         String action,
         String action,
-        String indices,
+        String[] indices,
         String requestName,
         String requestName,
         InetSocketAddress remoteAddress,
         InetSocketAddress remoteAddress,
         AuthorizationInfo authorizationInfo
         AuthorizationInfo authorizationInfo

+ 1 - 1
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/audit/AuditTrailService.java

@@ -186,7 +186,7 @@ public class AuditTrailService {
             AuditLevel eventType,
             AuditLevel eventType,
             Authentication authentication,
             Authentication authentication,
             String action,
             String action,
-            String indices,
+            String[] indices,
             String requestName,
             String requestName,
             InetSocketAddress remoteAddress,
             InetSocketAddress remoteAddress,
             AuthorizationInfo authorizationInfo
             AuthorizationInfo authorizationInfo

+ 2 - 3
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/audit/logfile/LoggingAuditTrail.java

@@ -811,13 +811,12 @@ public class LoggingAuditTrail implements AuditTrail, ClusterStateListener {
         AuditLevel eventType,
         AuditLevel eventType,
         Authentication authentication,
         Authentication authentication,
         String action,
         String action,
-        String index,
+        String[] indices,
         String requestName,
         String requestName,
         InetSocketAddress remoteAddress,
         InetSocketAddress remoteAddress,
         AuthorizationInfo authorizationInfo
         AuthorizationInfo authorizationInfo
     ) {
     ) {
         assert eventType == ACCESS_DENIED || eventType == AuditLevel.ACCESS_GRANTED || eventType == SYSTEM_ACCESS_GRANTED;
         assert eventType == ACCESS_DENIED || eventType == AuditLevel.ACCESS_GRANTED || eventType == SYSTEM_ACCESS_GRANTED;
-        final String[] indices = index == null ? null : new String[] { index };
         final User user = authentication.getEffectiveSubject().getUser();
         final User user = authentication.getEffectiveSubject().getUser();
         if (user instanceof InternalUser && eventType == ACCESS_GRANTED) {
         if (user instanceof InternalUser && eventType == ACCESS_GRANTED) {
             eventType = SYSTEM_ACCESS_GRANTED;
             eventType = SYSTEM_ACCESS_GRANTED;
@@ -830,7 +829,7 @@ public class LoggingAuditTrail implements AuditTrail, ClusterStateListener {
                         // can be null for API keys created before version 7.7
                         // can be null for API keys created before version 7.7
                         Optional.ofNullable(ApiKeyService.getCreatorRealmName(authentication)),
                         Optional.ofNullable(ApiKeyService.getCreatorRealmName(authentication)),
                         Optional.of(authorizationInfo),
                         Optional.of(authorizationInfo),
-                        Optional.ofNullable(indices),
+                        Optional.of(indices),
                         Optional.of(action)
                         Optional.of(action)
                     )
                     )
                 ) == false) {
                 ) == false) {

+ 64 - 51
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationService.java

@@ -750,32 +750,16 @@ public class AuthorizationService {
         final BulkShardRequest request = (BulkShardRequest) requestInfo.getRequest();
         final BulkShardRequest request = (BulkShardRequest) requestInfo.getRequest();
         // Maps original-index -> expanded-index-name (expands date-math, but not aliases)
         // Maps original-index -> expanded-index-name (expands date-math, but not aliases)
         final Map<String, String> resolvedIndexNames = new HashMap<>();
         final Map<String, String> resolvedIndexNames = new HashMap<>();
-        // Maps action -> resolved indices set
-        final Map<String, Set<String>> actionToIndicesMap = new HashMap<>();
+        // Maps action -> resolved indices set (there are 4 action types total)
+        final Map<String, Set<String>> actionToIndicesMap = new HashMap<>(4);
         final AuditTrail auditTrail = auditTrailService.get();
         final AuditTrail auditTrail = auditTrailService.get();
 
 
         resolvedIndicesAsyncSupplier.getAsync(ActionListener.wrap(overallResolvedIndices -> {
         resolvedIndicesAsyncSupplier.getAsync(ActionListener.wrap(overallResolvedIndices -> {
             final Set<String> localIndices = new HashSet<>(overallResolvedIndices.getLocal());
             final Set<String> localIndices = new HashSet<>(overallResolvedIndices.getLocal());
             for (BulkItemRequest item : request.items()) {
             for (BulkItemRequest item : request.items()) {
                 final String itemAction = getAction(item);
                 final String itemAction = getAction(item);
-                String resolvedIndex = resolvedIndexNames.computeIfAbsent(item.index(), key -> {
-                    final ResolvedIndices resolvedIndices = IndicesAndAliasesResolver.resolveIndicesAndAliasesWithoutWildcards(
-                        itemAction,
-                        item.request()
-                    );
-                    if (resolvedIndices.getRemote().size() != 0) {
-                        throw illegalArgument(
-                            "Bulk item should not write to remote indices, but request writes to "
-                                + String.join(",", resolvedIndices.getRemote())
-                        );
-                    }
-                    if (resolvedIndices.getLocal().size() != 1) {
-                        throw illegalArgument(
-                            "Bulk item should write to exactly 1 index, but request writes to "
-                                + String.join(",", resolvedIndices.getLocal())
-                        );
-                    }
-                    final String resolved = resolvedIndices.getLocal().get(0);
+                final String resolvedIndex = resolvedIndexNames.computeIfAbsent(item.index(), key -> {
+                    final String resolved = resolveIndexNameDateMath(item);
                     if (localIndices.contains(resolved) == false) {
                     if (localIndices.contains(resolved) == false) {
                         throw illegalArgument(
                         throw illegalArgument(
                             "Found bulk item that writes to index " + resolved + " but the request writes to " + localIndices
                             "Found bulk item that writes to index " + resolved + " but the request writes to " + localIndices
@@ -783,17 +767,12 @@ public class AuthorizationService {
                     }
                     }
                     return resolved;
                     return resolved;
                 });
                 });
-
-                actionToIndicesMap.compute(itemAction, (key, resolvedIndicesSet) -> {
-                    final Set<String> localSet = resolvedIndicesSet != null ? resolvedIndicesSet : new HashSet<>();
-                    localSet.add(resolvedIndex);
-                    return localSet;
-                });
+                actionToIndicesMap.compute(itemAction, (ignore, resolvedIndicesSet) -> addToOrCreateSet(resolvedIndicesSet, resolvedIndex));
             }
             }
 
 
             final ActionListener<Collection<Tuple<String, IndexAuthorizationResult>>> bulkAuthzListener = ActionListener.wrap(
             final ActionListener<Collection<Tuple<String, IndexAuthorizationResult>>> bulkAuthzListener = ActionListener.wrap(
                 collection -> {
                 collection -> {
-                    final Map<String, IndicesAccessControl> actionToIndicesAccessControl = new HashMap<>();
+                    final Map<String, IndicesAccessControl> actionToIndicesAccessControl = new HashMap<>(4);
                     collection.forEach(tuple -> {
                     collection.forEach(tuple -> {
                         final IndicesAccessControl existing = actionToIndicesAccessControl.putIfAbsent(
                         final IndicesAccessControl existing = actionToIndicesAccessControl.putIfAbsent(
                             tuple.v1(),
                             tuple.v1(),
@@ -804,23 +783,20 @@ public class AuthorizationService {
                         }
                         }
                     });
                     });
 
 
+                    final Map<String, Set<String>> actionToGrantedIndicesMap = new HashMap<>(4);
+                    final Map<String, Set<String>> actionToDeniedIndicesMap = new HashMap<>(4);
                     for (BulkItemRequest item : request.items()) {
                     for (BulkItemRequest item : request.items()) {
                         final String resolvedIndex = resolvedIndexNames.get(item.index());
                         final String resolvedIndex = resolvedIndexNames.get(item.index());
                         final String itemAction = getAction(item);
                         final String itemAction = getAction(item);
-                        final IndicesAccessControl indicesAccessControl = actionToIndicesAccessControl.get(itemAction);
-                        final IndicesAccessControl.IndexAccessControl indexAccessControl = indicesAccessControl.getIndexPermissions(
-                            resolvedIndex
-                        );
-                        if (indexAccessControl == null) {
-                            auditTrail.explicitIndexAccessEvent(
-                                requestId,
-                                AuditLevel.ACCESS_DENIED,
-                                authentication,
+                        if (actionToIndicesAccessControl.get(itemAction).hasIndexPermissions(resolvedIndex)) {
+                            actionToGrantedIndicesMap.compute(
                                 itemAction,
                                 itemAction,
-                                resolvedIndex,
-                                item.getClass().getSimpleName(),
-                                request.remoteAddress(),
-                                authzInfo
+                                (ignore, resolvedIndicesSet) -> addToOrCreateSet(resolvedIndicesSet, resolvedIndex)
+                            );
+                        } else {
+                            actionToDeniedIndicesMap.compute(
+                                itemAction,
+                                (ignore, resolvedIndicesSet) -> addToOrCreateSet(resolvedIndicesSet, resolvedIndex)
                             );
                             );
                             item.abort(
                             item.abort(
                                 resolvedIndex,
                                 resolvedIndex,
@@ -833,19 +809,32 @@ public class AuthorizationService {
                                     null
                                     null
                                 )
                                 )
                             );
                             );
-                        } else {
-                            auditTrail.explicitIndexAccessEvent(
-                                requestId,
-                                AuditLevel.ACCESS_GRANTED,
-                                authentication,
-                                itemAction,
-                                resolvedIndex,
-                                item.getClass().getSimpleName(),
-                                request.remoteAddress(),
-                                authzInfo
-                            );
                         }
                         }
                     }
                     }
+                    actionToDeniedIndicesMap.forEach((action, resolvedIndicesSet) -> {
+                        auditTrail.explicitIndexAccessEvent(
+                            requestId,
+                            AuditLevel.ACCESS_DENIED,
+                            authentication,
+                            action,
+                            resolvedIndicesSet.toArray(new String[0]),
+                            BulkItemRequest.class.getSimpleName(),
+                            request.remoteAddress(),
+                            authzInfo
+                        );
+                    });
+                    actionToGrantedIndicesMap.forEach((action, resolvedIndicesSet) -> {
+                        auditTrail.explicitIndexAccessEvent(
+                            requestId,
+                            AuditLevel.ACCESS_GRANTED,
+                            authentication,
+                            action,
+                            resolvedIndicesSet.toArray(new String[0]),
+                            BulkItemRequest.class.getSimpleName(),
+                            request.remoteAddress(),
+                            authzInfo
+                        );
+                    });
                     listener.onResponse(null);
                     listener.onResponse(null);
                 },
                 },
                 listener::onFailure
                 listener::onFailure
@@ -876,6 +865,30 @@ public class AuthorizationService {
         }, listener::onFailure));
         }, listener::onFailure));
     }
     }
 
 
+    private static Set<String> addToOrCreateSet(Set<String> set, String item) {
+        final Set<String> localSet = set != null ? set : new HashSet<>(4);
+        localSet.add(item);
+        return localSet;
+    }
+
+    private static String resolveIndexNameDateMath(BulkItemRequest bulkItemRequest) {
+        final ResolvedIndices resolvedIndices = IndicesAndAliasesResolver.resolveIndicesAndAliasesWithoutWildcards(
+            getAction(bulkItemRequest),
+            bulkItemRequest.request()
+        );
+        if (resolvedIndices.getRemote().size() != 0) {
+            throw illegalArgument(
+                "Bulk item should not write to remote indices, but request writes to " + String.join(",", resolvedIndices.getRemote())
+            );
+        }
+        if (resolvedIndices.getLocal().size() != 1) {
+            throw illegalArgument(
+                "Bulk item should write to exactly 1 index, but request writes to " + String.join(",", resolvedIndices.getLocal())
+            );
+        }
+        return resolvedIndices.getLocal().get(0);
+    }
+
     private static IllegalArgumentException illegalArgument(String message) {
     private static IllegalArgumentException illegalArgument(String message) {
         assert false : message;
         assert false : message;
         return new IllegalArgumentException(message);
         return new IllegalArgumentException(message);

+ 4 - 6
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/audit/logfile/LoggingAuditTrailTests.java

@@ -2027,7 +2027,7 @@ public class LoggingAuditTrailTests extends ESTestCase {
             randomFrom(AuditLevel.ACCESS_GRANTED, AuditLevel.SYSTEM_ACCESS_GRANTED),
             randomFrom(AuditLevel.ACCESS_GRANTED, AuditLevel.SYSTEM_ACCESS_GRANTED),
             authentication,
             authentication,
             "_action",
             "_action",
-            randomFrom(randomAlphaOfLengthBetween(1, 4), null),
+            new String[] { randomAlphaOfLengthBetween(1, 4) },
             BulkItemRequest.class.getName(),
             BulkItemRequest.class.getName(),
             request.remoteAddress(),
             request.remoteAddress(),
             authorizationInfo
             authorizationInfo
@@ -2059,13 +2059,13 @@ public class LoggingAuditTrailTests extends ESTestCase {
         assertMsg(logger, checkedFields, checkedArrayFields);
         assertMsg(logger, checkedFields, checkedArrayFields);
         clearLog();
         clearLog();
 
 
-        String index = randomFrom(randomAlphaOfLengthBetween(1, 4), null);
+        String[] indices = randomArray(0, 4, String[]::new, () -> randomBoolean() ? null : randomAlphaOfLengthBetween(1, 4));
         auditTrail.explicitIndexAccessEvent(
         auditTrail.explicitIndexAccessEvent(
             requestId,
             requestId,
             randomFrom(AuditLevel.ACCESS_GRANTED, AuditLevel.SYSTEM_ACCESS_GRANTED),
             randomFrom(AuditLevel.ACCESS_GRANTED, AuditLevel.SYSTEM_ACCESS_GRANTED),
             authentication,
             authentication,
             "_action",
             "_action",
-            index,
+            indices,
             BulkItemRequest.class.getName(),
             BulkItemRequest.class.getName(),
             request.remoteAddress(),
             request.remoteAddress(),
             authorizationInfo
             authorizationInfo
@@ -2084,9 +2084,7 @@ public class LoggingAuditTrailTests extends ESTestCase {
         opaqueId(threadContext, checkedFields);
         opaqueId(threadContext, checkedFields);
         traceId(threadContext, checkedFields);
         traceId(threadContext, checkedFields);
         forwardedFor(threadContext, checkedFields);
         forwardedFor(threadContext, checkedFields);
-        if (index != null) {
-            checkedArrayFields.put(LoggingAuditTrail.INDICES_FIELD_NAME, new String[] { index });
-        }
+        checkedArrayFields.put(LoggingAuditTrail.INDICES_FIELD_NAME, indices);
         assertMsg(logger, checkedFields, checkedArrayFields);
         assertMsg(logger, checkedFields, checkedArrayFields);
     }
     }
 
 

+ 420 - 31
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/AuthorizationServiceTests.java

@@ -189,9 +189,11 @@ import java.util.Set;
 import java.util.UUID;
 import java.util.UUID;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
 import java.util.function.BiFunction;
 import java.util.function.BiFunction;
 import java.util.function.Consumer;
 import java.util.function.Consumer;
 import java.util.function.Predicate;
 import java.util.function.Predicate;
+import java.util.function.Supplier;
 
 
 import static java.util.Arrays.asList;
 import static java.util.Arrays.asList;
 import static org.elasticsearch.test.ActionListenerUtils.anyActionListener;
 import static org.elasticsearch.test.ActionListenerUtils.anyActionListener;
@@ -224,6 +226,7 @@ import static org.hamcrest.Matchers.nullValue;
 import static org.hamcrest.Matchers.sameInstance;
 import static org.hamcrest.Matchers.sameInstance;
 import static org.hamcrest.Matchers.startsWith;
 import static org.hamcrest.Matchers.startsWith;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.argThat;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.mock;
@@ -2404,7 +2407,396 @@ public class AuthorizationServiceTests extends ESTestCase {
         assertThrowsAuthorizationException(() -> authorize(createAuthentication(userDenied), action, request), action, "userDenied");
         assertThrowsAuthorizationException(() -> authorize(createAuthentication(userDenied), action, request), action, "userDenied");
     }
     }
 
 
-    public void testAuthorizationOfIndividualBulkItems() {
+    public void testAuthorizationOfSingleActionMultipleIndicesBulkItems() {
+        final String action = BulkAction.NAME + "[s]";
+        final BulkItemRequest[] items;
+        final DocWriteRequest.OpType opType = randomFrom(DocWriteRequest.OpType.values());
+        // the "good role" authorizes all the bulk items
+        final RoleDescriptor goodRole;
+        // the "bad role" does not authorize any bulk items
+        final RoleDescriptor badRole;
+        final AtomicInteger idCounter = new AtomicInteger();
+        final Set<String> allIndexNames = new HashSet<>();
+        final Supplier<String> indexNameSupplier = () -> {
+            String indexName = randomAlphaOfLengthBetween(1, 4);
+            allIndexNames.add(indexName);
+            return indexName;
+        };
+        // build a request with bulk items of the same action type, but multiple index names
+        switch (opType) {
+            case INDEX -> {
+                items = randomArray(
+                    1,
+                    8,
+                    BulkItemRequest[]::new,
+                    () -> new BulkItemRequest(
+                        idCounter.get(),
+                        new IndexRequest(indexNameSupplier.get()).id("id" + idCounter.incrementAndGet())
+                            .opType(DocWriteRequest.OpType.INDEX)
+                    )
+                );
+                goodRole = new RoleDescriptor(
+                    "good-role",
+                    null,
+                    allIndexNames.stream()
+                        .map(
+                            indexName -> IndicesPrivileges.builder()
+                                .indices(indexName)
+                                .privileges(randomFrom("all", "create", "index", "write"))
+                                .build()
+                        )
+                        .toArray(IndicesPrivileges[]::new),
+                    null
+                );
+                badRole = new RoleDescriptor(
+                    "bad-role",
+                    null,
+                    allIndexNames.stream()
+                        .map(
+                            indexName -> IndicesPrivileges.builder()
+                                .indices(indexName)
+                                .privileges(randomFrom("create_doc", "delete"))
+                                .build()
+                        )
+                        .toArray(IndicesPrivileges[]::new),
+                    null
+                );
+            }
+            case CREATE -> {
+                items = randomArray(
+                    1,
+                    8,
+                    BulkItemRequest[]::new,
+                    () -> new BulkItemRequest(
+                        idCounter.get(),
+                        new IndexRequest(indexNameSupplier.get()).id("id" + idCounter.incrementAndGet())
+                            .opType(DocWriteRequest.OpType.CREATE)
+                    )
+                );
+                goodRole = new RoleDescriptor(
+                    "good-role",
+                    null,
+                    allIndexNames.stream()
+                        .map(
+                            indexName -> IndicesPrivileges.builder()
+                                .indices(indexName)
+                                .privileges(randomFrom("all", "create_doc", "create", "index", "write"))
+                                .build()
+                        )
+                        .toArray(IndicesPrivileges[]::new),
+                    null
+                );
+                badRole = new RoleDescriptor(
+                    "bad-role",
+                    null,
+                    allIndexNames.stream()
+                        .map(indexName -> IndicesPrivileges.builder().indices(indexName).privileges("delete").build())
+                        .toArray(IndicesPrivileges[]::new),
+                    null
+                );
+            }
+            case DELETE -> {
+                items = randomArray(
+                    1,
+                    8,
+                    BulkItemRequest[]::new,
+                    () -> new BulkItemRequest(
+                        idCounter.get(),
+                        new DeleteRequest(indexNameSupplier.get(), "id" + idCounter.incrementAndGet())
+                    )
+                );
+                goodRole = new RoleDescriptor(
+                    "good-role",
+                    null,
+                    allIndexNames.stream()
+                        .map(
+                            indexName -> IndicesPrivileges.builder()
+                                .indices(indexName)
+                                .privileges(randomFrom("all", "delete", "write"))
+                                .build()
+                        )
+                        .toArray(IndicesPrivileges[]::new),
+                    null
+                );
+                badRole = new RoleDescriptor(
+                    "bad-role",
+                    null,
+                    allIndexNames.stream()
+                        .map(
+                            indexName -> IndicesPrivileges.builder()
+                                .indices(indexName)
+                                .privileges(randomFrom("index", "create", "create_doc"))
+                                .build()
+                        )
+                        .toArray(IndicesPrivileges[]::new),
+                    null
+                );
+            }
+            case UPDATE -> {
+                items = randomArray(
+                    1,
+                    8,
+                    BulkItemRequest[]::new,
+                    () -> new BulkItemRequest(
+                        idCounter.get(),
+                        new UpdateRequest(indexNameSupplier.get(), "id" + idCounter.incrementAndGet())
+                    )
+                );
+                goodRole = new RoleDescriptor(
+                    "good-role",
+                    null,
+                    allIndexNames.stream()
+                        .map(
+                            indexName -> IndicesPrivileges.builder()
+                                .indices(indexName)
+                                .privileges(randomFrom("all", "index", "write"))
+                                .build()
+                        )
+                        .toArray(IndicesPrivileges[]::new),
+                    null
+                );
+                badRole = new RoleDescriptor(
+                    "bad-role",
+                    null,
+                    allIndexNames.stream()
+                        .map(
+                            indexName -> IndicesPrivileges.builder()
+                                .indices(indexName)
+                                .privileges(randomFrom("create", "create_doc", "delete"))
+                                .build()
+                        )
+                        .toArray(IndicesPrivileges[]::new),
+                    null
+                );
+            }
+            default -> throw new IllegalStateException("Unexpected value: " + opType);
+        }
+        roleMap.put("good-role", goodRole);
+        roleMap.put("bad-role", badRole);
+
+        final ShardId shardId = new ShardId("some-concrete-shard-index-name", UUID.randomUUID().toString(), 1);
+        final BulkShardRequest request = new BulkShardRequest(shardId, randomFrom(WriteRequest.RefreshPolicy.values()), items);
+
+        mockEmptyMetadata();
+        final Authentication authentication;
+        final String requestId;
+        try (ThreadContext.StoredContext ignore = threadContext.stashContext()) {
+            authentication = createAuthentication(new User("user", "good-role"));
+            requestId = AuditUtil.getOrGenerateRequestId(threadContext);
+            authorize(authentication, action, request);
+        }
+
+        // bulk shard request is authorized
+        verify(auditTrail).accessGranted(
+            eq(requestId),
+            eq(authentication),
+            eq(action),
+            eq(request),
+            authzInfoRoles(new String[] { goodRole.getName() })
+        );
+        // there's only one "access granted" record for all the bulk items
+        verify(auditTrail).explicitIndexAccessEvent(eq(requestId), eq(AuditLevel.ACCESS_GRANTED), eq(authentication), eq(switch (opType) {
+            case INDEX -> IndexAction.NAME + ":op_type/index";
+            case CREATE -> IndexAction.NAME + ":op_type/create";
+            case UPDATE -> UpdateAction.NAME;
+            case DELETE -> DeleteAction.NAME;
+        }),
+            argThat(
+                indicesArrays -> indicesArrays.length == allIndexNames.size() && allIndexNames.containsAll(Arrays.asList(indicesArrays))
+            ),
+            eq(BulkItemRequest.class.getSimpleName()),
+            eq(request.remoteAddress()),
+            authzInfoRoles(new String[] { goodRole.getName() })
+        );
+        verifyNoMoreInteractions(auditTrail);
+        // all bulk items go through as authorized
+        for (BulkItemRequest bulkItemRequest : request.items()) {
+            assertThat(bulkItemRequest.getPrimaryResponse(), nullValue());
+        }
+
+        final Authentication badAuthentication;
+        final String badRequestId;
+        try (ThreadContext.StoredContext ignore = threadContext.stashContext()) {
+            badAuthentication = createAuthentication(new User("bad-user", "bad-role"));
+            badRequestId = AuditUtil.getOrGenerateRequestId(threadContext);
+            // the bulk shard request is authorized, but the bulk items are not
+            authorize(badAuthentication, action, request);
+        }
+        // bulk shard request is authorized
+        verify(auditTrail).accessGranted(
+            eq(badRequestId),
+            eq(badAuthentication),
+            eq(action),
+            eq(request),
+            authzInfoRoles(new String[] { badRole.getName() })
+        );
+        // there's only one "access denied" record for all the bulk items
+        verify(auditTrail).explicitIndexAccessEvent(
+            eq(badRequestId),
+            eq(AuditLevel.ACCESS_DENIED),
+            eq(badAuthentication),
+            eq(switch (opType) {
+                case INDEX -> IndexAction.NAME + ":op_type/index";
+                case CREATE -> IndexAction.NAME + ":op_type/create";
+                case UPDATE -> UpdateAction.NAME;
+                case DELETE -> DeleteAction.NAME;
+            }),
+            argThat(
+                indicesArrays -> indicesArrays.length == allIndexNames.size() && allIndexNames.containsAll(Arrays.asList(indicesArrays))
+            ),
+            eq(BulkItemRequest.class.getSimpleName()),
+            eq(request.remoteAddress()),
+            authzInfoRoles(new String[] { badRole.getName() })
+        );
+        verifyNoMoreInteractions(auditTrail);
+        // all bulk items are failures
+        for (BulkItemRequest bulkItemRequest : request.items()) {
+            assertThat(bulkItemRequest.getPrimaryResponse().isFailed(), is(true));
+        }
+    }
+
+    public void testAuthorizationOfMultipleActionsSingleIndexBulkItems() {
+        final String action = BulkAction.NAME + "[s]";
+        final AtomicInteger idCounter = new AtomicInteger();
+        final Set<String> actionTypes = new HashSet<>();
+        final Set<Integer> deleteItems = new HashSet<>();
+        final String indexName = randomAlphaOfLengthBetween(1, 4);
+        final BulkItemRequest[] items = randomArray(1, 8, BulkItemRequest[]::new, () -> {
+            switch (randomFrom(DocWriteRequest.OpType.values())) {
+                case INDEX -> {
+                    actionTypes.add(IndexAction.NAME + ":op_type/index");
+                    return new BulkItemRequest(
+                        idCounter.get(),
+                        new IndexRequest(indexName).id("id" + idCounter.incrementAndGet()).opType(DocWriteRequest.OpType.INDEX)
+                    );
+                }
+                case CREATE -> {
+                    actionTypes.add(IndexAction.NAME + ":op_type/create");
+                    return new BulkItemRequest(
+                        idCounter.get(),
+                        new IndexRequest(indexName).id("id" + idCounter.incrementAndGet()).opType(DocWriteRequest.OpType.CREATE)
+                    );
+                }
+                case DELETE -> {
+                    actionTypes.add(DeleteAction.NAME);
+                    deleteItems.add(idCounter.get());
+                    return new BulkItemRequest(idCounter.get(), new DeleteRequest(indexName, "id" + idCounter.incrementAndGet()));
+                }
+                case UPDATE -> {
+                    actionTypes.add(UpdateAction.NAME);
+                    return new BulkItemRequest(idCounter.get(), new UpdateRequest(indexName, "id" + idCounter.incrementAndGet()));
+                }
+                default -> throw new IllegalStateException("Unexpected value");
+            }
+        });
+        RoleDescriptor allRole = new RoleDescriptor(
+            "all-role",
+            null,
+            new IndicesPrivileges[] { IndicesPrivileges.builder().indices(indexName).privileges(randomFrom("all", "write")).build() },
+            null
+        );
+        RoleDescriptor indexRole = new RoleDescriptor(
+            "index-role",
+            null,
+            new IndicesPrivileges[] { IndicesPrivileges.builder().indices(indexName).privileges("index").build() },
+            null
+        );
+        roleMap.put("all-role", allRole);
+        roleMap.put("index-role", indexRole);
+
+        final ShardId shardId = new ShardId(indexName, UUID.randomUUID().toString(), 1);
+        final BulkShardRequest request = new BulkShardRequest(shardId, randomFrom(WriteRequest.RefreshPolicy.values()), items);
+
+        mockEmptyMetadata();
+        final Authentication authentication;
+        final String requestId;
+        try (ThreadContext.StoredContext ignore = threadContext.stashContext()) {
+            authentication = createAuthentication(new User("user", "all-role"));
+            requestId = AuditUtil.getOrGenerateRequestId(threadContext);
+            authorize(authentication, action, request);
+        }
+        // bulk shard request is authorized
+        verify(auditTrail).accessGranted(
+            eq(requestId),
+            eq(authentication),
+            eq(action),
+            eq(request),
+            authzInfoRoles(new String[] { allRole.getName() })
+        );
+        // there's one granted audit entry for each action type
+        actionTypes.forEach(actionType -> {
+            verify(auditTrail).explicitIndexAccessEvent(
+                eq(requestId),
+                eq(AuditLevel.ACCESS_GRANTED),
+                eq(authentication),
+                eq(actionType),
+                eq(new String[] { indexName }),
+                eq(BulkItemRequest.class.getSimpleName()),
+                eq(request.remoteAddress()),
+                authzInfoRoles(new String[] { allRole.getName() })
+            );
+        });
+        verifyNoMoreInteractions(auditTrail);
+        // all bulk items go through as authorized
+        for (BulkItemRequest bulkItemRequest : request.items()) {
+            assertThat(bulkItemRequest.getPrimaryResponse(), nullValue());
+        }
+
+        // use the "index" role
+        final Authentication indexAuthentication;
+        final String indexRequestId;
+        try (ThreadContext.StoredContext ignore = threadContext.stashContext()) {
+            indexAuthentication = createAuthentication(new User("index-user", "index-role"));
+            indexRequestId = AuditUtil.getOrGenerateRequestId(threadContext);
+            authorize(indexAuthentication, action, request);
+        }
+        // bulk shard request is authorized
+        verify(auditTrail).accessGranted(
+            eq(indexRequestId),
+            eq(indexAuthentication),
+            eq(action),
+            eq(request),
+            authzInfoRoles(new String[] { indexRole.getName() })
+        );
+        // there's a single granted audit entry for each action type, less the delete action (which is denied)
+        actionTypes.forEach(actionType -> {
+            if (actionType.equals(DeleteAction.NAME) == false) {
+                verify(auditTrail).explicitIndexAccessEvent(
+                    eq(indexRequestId),
+                    eq(AuditLevel.ACCESS_GRANTED),
+                    eq(indexAuthentication),
+                    eq(actionType),
+                    eq(new String[] { indexName }),
+                    eq(BulkItemRequest.class.getSimpleName()),
+                    eq(request.remoteAddress()),
+                    authzInfoRoles(new String[] { indexRole.getName() })
+                );
+            }
+        });
+        if (deleteItems.isEmpty() == false) {
+            // there's one denied audit entry for all the delete action types
+            verify(auditTrail).explicitIndexAccessEvent(
+                eq(indexRequestId),
+                eq(AuditLevel.ACCESS_DENIED),
+                eq(indexAuthentication),
+                eq(DeleteAction.NAME),
+                eq(new String[] { indexName }),
+                eq(BulkItemRequest.class.getSimpleName()),
+                eq(request.remoteAddress()),
+                authzInfoRoles(new String[] { indexRole.getName() })
+            );
+        }
+        verifyNoMoreInteractions(auditTrail);
+        for (BulkItemRequest bulkItemRequest : request.items()) {
+            if (deleteItems.contains(bulkItemRequest.id())) {
+                assertThat(bulkItemRequest.getPrimaryResponse().isFailed(), is(true));
+            } else {
+                assertThat(bulkItemRequest.getPrimaryResponse(), nullValue());
+            }
+        }
+    }
+
+    public void testAuthorizationOfIndividualIndexAndDeleteBulkItems() {
         final String action = BulkAction.NAME + "[s]";
         final String action = BulkAction.NAME + "[s]";
         final BulkItemRequest[] items = {
         final BulkItemRequest[] items = {
             new BulkItemRequest(1, new DeleteRequest("concrete-index", "c1")),
             new BulkItemRequest(1, new DeleteRequest("concrete-index", "c1")),
@@ -2414,7 +2806,7 @@ public class AuthorizationServiceTests extends ESTestCase {
             new BulkItemRequest(5, new DeleteRequest("alias-2", "a2a")),
             new BulkItemRequest(5, new DeleteRequest("alias-2", "a2a")),
             new BulkItemRequest(6, new IndexRequest("alias-2").id("a2b")) };
             new BulkItemRequest(6, new IndexRequest("alias-2").id("a2b")) };
         final ShardId shardId = new ShardId("concrete-index", UUID.randomUUID().toString(), 1);
         final ShardId shardId = new ShardId("concrete-index", UUID.randomUUID().toString(), 1);
-        final TransportRequest request = new BulkShardRequest(shardId, WriteRequest.RefreshPolicy.IMMEDIATE, items);
+        final BulkShardRequest request = new BulkShardRequest(shardId, WriteRequest.RefreshPolicy.IMMEDIATE, items);
 
 
         final Authentication authentication = createAuthentication(new User("user", "my-role"));
         final Authentication authentication = createAuthentication(new User("user", "my-role"));
         RoleDescriptor role = new RoleDescriptor(
         RoleDescriptor role = new RoleDescriptor(
@@ -2437,27 +2829,10 @@ public class AuthorizationServiceTests extends ESTestCase {
             eq(AuditLevel.ACCESS_GRANTED),
             eq(AuditLevel.ACCESS_GRANTED),
             eq(authentication),
             eq(authentication),
             eq(DeleteAction.NAME),
             eq(DeleteAction.NAME),
-            eq("concrete-index"),
-            eq(BulkItemRequest.class.getSimpleName()),
-            eq(request.remoteAddress()),
-            authzInfoRoles(new String[] { role.getName() })
-        );
-        verify(auditTrail).explicitIndexAccessEvent(
-            eq(requestId),
-            eq(AuditLevel.ACCESS_GRANTED),
-            eq(authentication),
-            eq(DeleteAction.NAME),
-            eq("alias-2"),
-            eq(BulkItemRequest.class.getSimpleName()),
-            eq(request.remoteAddress()),
-            authzInfoRoles(new String[] { role.getName() })
-        );
-        verify(auditTrail).explicitIndexAccessEvent(
-            eq(requestId),
-            eq(AuditLevel.ACCESS_GRANTED),
-            eq(authentication),
-            eq(IndexAction.NAME + ":op_type/index"),
-            eq("concrete-index"),
+            argThat(indicesArrays -> {
+                Arrays.sort(indicesArrays);
+                return Arrays.equals(indicesArrays, new String[] { "alias-2", "concrete-index" });
+            }),
             eq(BulkItemRequest.class.getSimpleName()),
             eq(BulkItemRequest.class.getSimpleName()),
             eq(request.remoteAddress()),
             eq(request.remoteAddress()),
             authzInfoRoles(new String[] { role.getName() })
             authzInfoRoles(new String[] { role.getName() })
@@ -2467,7 +2842,10 @@ public class AuthorizationServiceTests extends ESTestCase {
             eq(AuditLevel.ACCESS_GRANTED),
             eq(AuditLevel.ACCESS_GRANTED),
             eq(authentication),
             eq(authentication),
             eq(IndexAction.NAME + ":op_type/index"),
             eq(IndexAction.NAME + ":op_type/index"),
-            eq("alias-1"),
+            argThat(indicesArrays -> {
+                Arrays.sort(indicesArrays);
+                return Arrays.equals(indicesArrays, new String[] { "alias-1", "concrete-index" });
+            }),
             eq(BulkItemRequest.class.getSimpleName()),
             eq(BulkItemRequest.class.getSimpleName()),
             eq(request.remoteAddress()),
             eq(request.remoteAddress()),
             authzInfoRoles(new String[] { role.getName() })
             authzInfoRoles(new String[] { role.getName() })
@@ -2477,7 +2855,7 @@ public class AuthorizationServiceTests extends ESTestCase {
             eq(AuditLevel.ACCESS_DENIED),
             eq(AuditLevel.ACCESS_DENIED),
             eq(authentication),
             eq(authentication),
             eq(DeleteAction.NAME),
             eq(DeleteAction.NAME),
-            eq("alias-1"),
+            eq(new String[] { "alias-1" }),
             eq(BulkItemRequest.class.getSimpleName()),
             eq(BulkItemRequest.class.getSimpleName()),
             eq(request.remoteAddress()),
             eq(request.remoteAddress()),
             authzInfoRoles(new String[] { role.getName() })
             authzInfoRoles(new String[] { role.getName() })
@@ -2487,7 +2865,7 @@ public class AuthorizationServiceTests extends ESTestCase {
             eq(AuditLevel.ACCESS_DENIED),
             eq(AuditLevel.ACCESS_DENIED),
             eq(authentication),
             eq(authentication),
             eq(IndexAction.NAME + ":op_type/index"),
             eq(IndexAction.NAME + ":op_type/index"),
-            eq("alias-2"),
+            eq(new String[] { "alias-2" }),
             eq(BulkItemRequest.class.getSimpleName()),
             eq(BulkItemRequest.class.getSimpleName()),
             eq(request.remoteAddress()),
             eq(request.remoteAddress()),
             authzInfoRoles(new String[] { role.getName() })
             authzInfoRoles(new String[] { role.getName() })
@@ -2500,6 +2878,12 @@ public class AuthorizationServiceTests extends ESTestCase {
             authzInfoRoles(new String[] { role.getName() })
             authzInfoRoles(new String[] { role.getName() })
         ); // bulk request is allowed
         ); // bulk request is allowed
         verifyNoMoreInteractions(auditTrail);
         verifyNoMoreInteractions(auditTrail);
+        assertThat(request.items()[0].getPrimaryResponse(), nullValue());
+        assertThat(request.items()[1].getPrimaryResponse(), nullValue());
+        assertThat(request.items()[2].getPrimaryResponse().isFailed(), is(true));
+        assertThat(request.items()[3].getPrimaryResponse(), nullValue());
+        assertThat(request.items()[4].getPrimaryResponse(), nullValue());
+        assertThat(request.items()[5].getPrimaryResponse().isFailed(), is(true));
     }
     }
 
 
     public void testAuthorizationOfIndividualBulkItemsWithDateMath() {
     public void testAuthorizationOfIndividualBulkItemsWithDateMath() {
@@ -2511,7 +2895,7 @@ public class AuthorizationServiceTests extends ESTestCase {
             new BulkItemRequest(4, new DeleteRequest("<datemath-{now/d{YYYY.MM}}>", "dm2")), // resolves to same as above
             new BulkItemRequest(4, new DeleteRequest("<datemath-{now/d{YYYY.MM}}>", "dm2")), // resolves to same as above
         };
         };
         final ShardId shardId = new ShardId("concrete-index", UUID.randomUUID().toString(), 1);
         final ShardId shardId = new ShardId("concrete-index", UUID.randomUUID().toString(), 1);
-        final TransportRequest request = new BulkShardRequest(shardId, WriteRequest.RefreshPolicy.IMMEDIATE, items);
+        final BulkShardRequest request = new BulkShardRequest(shardId, WriteRequest.RefreshPolicy.IMMEDIATE, items);
 
 
         final Authentication authentication = createAuthentication(new User("user", "my-role"));
         final Authentication authentication = createAuthentication(new User("user", "my-role"));
         final RoleDescriptor role = new RoleDescriptor(
         final RoleDescriptor role = new RoleDescriptor(
@@ -2527,22 +2911,23 @@ public class AuthorizationServiceTests extends ESTestCase {
         authorize(authentication, action, request);
         authorize(authentication, action, request);
 
 
         // both deletes should fail
         // both deletes should fail
-        verify(auditTrail, times(2)).explicitIndexAccessEvent(
+        verify(auditTrail).explicitIndexAccessEvent(
             eq(requestId),
             eq(requestId),
             eq(AuditLevel.ACCESS_DENIED),
             eq(AuditLevel.ACCESS_DENIED),
             eq(authentication),
             eq(authentication),
             eq(DeleteAction.NAME),
             eq(DeleteAction.NAME),
-            ArgumentMatchers.startsWith("datemath-"),
+            argThat(indices -> indices.length == 2 && indices[0].startsWith("datemath-") && indices[1].startsWith("datemath-")),
             eq(BulkItemRequest.class.getSimpleName()),
             eq(BulkItemRequest.class.getSimpleName()),
             eq(request.remoteAddress()),
             eq(request.remoteAddress()),
             authzInfoRoles(new String[] { role.getName() })
             authzInfoRoles(new String[] { role.getName() })
         );
         );
-        verify(auditTrail, times(2)).explicitIndexAccessEvent(
+        // both indexing should go through
+        verify(auditTrail).explicitIndexAccessEvent(
             eq(requestId),
             eq(requestId),
             eq(AuditLevel.ACCESS_GRANTED),
             eq(AuditLevel.ACCESS_GRANTED),
             eq(authentication),
             eq(authentication),
             eq(IndexAction.NAME + ":op_type/index"),
             eq(IndexAction.NAME + ":op_type/index"),
-            ArgumentMatchers.startsWith("datemath-"),
+            argThat(indices -> indices.length == 2 && indices[0].startsWith("datemath-") && indices[1].startsWith("datemath-")),
             eq(BulkItemRequest.class.getSimpleName()),
             eq(BulkItemRequest.class.getSimpleName()),
             eq(request.remoteAddress()),
             eq(request.remoteAddress()),
             authzInfoRoles(new String[] { role.getName() })
             authzInfoRoles(new String[] { role.getName() })
@@ -2556,6 +2941,10 @@ public class AuthorizationServiceTests extends ESTestCase {
             authzInfoRoles(new String[] { role.getName() })
             authzInfoRoles(new String[] { role.getName() })
         );
         );
         verifyNoMoreInteractions(auditTrail);
         verifyNoMoreInteractions(auditTrail);
+        assertThat(request.items()[0].getPrimaryResponse(), nullValue());
+        assertThat(request.items()[1].getPrimaryResponse().isFailed(), is(true));
+        assertThat(request.items()[2].getPrimaryResponse(), nullValue());
+        assertThat(request.items()[3].getPrimaryResponse().isFailed(), is(true));
     }
     }
 
 
     private BulkShardRequest createBulkShardRequest(String indexName, BiFunction<String, String, DocWriteRequest<?>> req) {
     private BulkShardRequest createBulkShardRequest(String indexName, BiFunction<String, String, DocWriteRequest<?>> req) {