Bladeren bron

cross check livedocs for terms aggs when index access control list is non-null (#105714)

Jake Landis 1 jaar geleden
bovenliggende
commit
75e67f0764
15 gewijzigde bestanden met toevoegingen van 547 en 55 verwijderingen
  1. 5 0
      docs/changelog/105714.yaml
  2. 1 0
      server/src/main/java/org/elasticsearch/TransportVersions.java
  3. 41 0
      server/src/main/java/org/elasticsearch/search/aggregations/AggregatorFactories.java
  4. 131 22
      server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/GlobalOrdinalsStringTermsAggregator.java
  5. 11 5
      server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/MapStringTermsAggregator.java
  6. 11 5
      server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/NumericTermsAggregator.java
  7. 6 3
      server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/SignificantTermsAggregatorFactory.java
  8. 2 1
      server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/SignificantTextAggregatorFactory.java
  9. 27 3
      server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/TermsAggregationBuilder.java
  10. 28 13
      server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/TermsAggregatorFactory.java
  11. 2 1
      server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/TermsAggregatorSupplier.java
  12. 37 0
      server/src/test/java/org/elasticsearch/search/aggregations/AggregatorFactoriesBuilderTests.java
  13. 128 1
      x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/integration/DocumentLevelSecurityTests.java
  14. 12 1
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/interceptor/SearchRequestInterceptor.java
  15. 105 0
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/interceptor/SearchRequestInterceptorTests.java

+ 5 - 0
docs/changelog/105714.yaml

@@ -0,0 +1,5 @@
+pr: 105714
+summary: Cross check livedocs for terms aggs when index access control list is non-null
+area: "Aggregations"
+type: bug
+issues: []

+ 1 - 0
server/src/main/java/org/elasticsearch/TransportVersions.java

@@ -146,6 +146,7 @@ public class TransportVersions {
     public static final TransportVersion KNN_EXPLICIT_BYTE_QUERY_VECTOR_PARSING = def(8_606_00_0);
     public static final TransportVersion ESQL_EXTENDED_ENRICH_INPUT_TYPE = def(8_607_00_0);
     public static final TransportVersion ESQL_SERIALIZE_BIG_VECTOR = def(8_608_00_0);
+    public static final TransportVersion AGGS_EXCLUDED_DELETED_DOCS = def(8_609_00_0);
 
     /*
      * STOP! READ THIS FIRST! No, really,

+ 41 - 0
server/src/main/java/org/elasticsearch/search/aggregations/AggregatorFactories.java

@@ -41,6 +41,7 @@ import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Queue;
 import java.util.Set;
 import java.util.function.ToLongFunction;
 import java.util.regex.Matcher;
@@ -334,6 +335,46 @@ public class AggregatorFactories {
             return false;
         }
 
+        /**
+         * Return true if any of the builders is a terms aggregation with min_doc_count=0
+         */
+        public boolean hasZeroMinDocTermsAggregation() {
+            final Queue<AggregationBuilder> queue = new LinkedList<>(aggregationBuilders);
+            while (queue.isEmpty() == false) {
+                final AggregationBuilder current = queue.poll();
+                if (current == null) {
+                    continue;
+                }
+                if (current instanceof TermsAggregationBuilder termsBuilder) {
+                    if (termsBuilder.minDocCount() == 0) {
+                        return true;
+                    }
+                }
+                queue.addAll(current.getSubAggregations());
+            }
+            return false;
+        }
+
+        /**
+         * Force all min_doc_count=0 terms aggregations to exclude deleted docs.
+         */
+        public void forceTermsAggsToExcludeDeletedDocs() {
+            assert hasZeroMinDocTermsAggregation();
+            final Queue<AggregationBuilder> queue = new LinkedList<>(aggregationBuilders);
+            while (queue.isEmpty() == false) {
+                final AggregationBuilder current = queue.poll();
+                if (current == null) {
+                    continue;
+                }
+                if (current instanceof TermsAggregationBuilder termsBuilder) {
+                    if (termsBuilder.minDocCount() == 0) {
+                        termsBuilder.excludeDeletedDocs(true);
+                    }
+                }
+                queue.addAll(current.getSubAggregations());
+            }
+        }
+
         /**
          * Return false if this aggregation or any of the child aggregations does not support parallel collection.
          * As a result, a request including such aggregation is always executed sequentially despite concurrency is enabled for the query

+ 131 - 22
server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/GlobalOrdinalsStringTermsAggregator.java

@@ -9,15 +9,20 @@
 package org.elasticsearch.search.aggregations.bucket.terms;
 
 import org.apache.lucene.index.DocValues;
+import org.apache.lucene.index.LeafReader;
+import org.apache.lucene.index.LeafReaderContext;
 import org.apache.lucene.index.SortedDocValues;
 import org.apache.lucene.index.SortedSetDocValues;
 import org.apache.lucene.util.ArrayUtil;
+import org.apache.lucene.util.Bits;
 import org.apache.lucene.util.BytesRef;
 import org.apache.lucene.util.PriorityQueue;
 import org.elasticsearch.common.CheckedSupplier;
 import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.common.util.BigArrays;
 import org.elasticsearch.common.util.LongArray;
 import org.elasticsearch.common.util.LongHash;
+import org.elasticsearch.core.Nullable;
 import org.elasticsearch.core.Releasable;
 import org.elasticsearch.core.Releasables;
 import org.elasticsearch.search.DocValueFormat;
@@ -85,7 +90,8 @@ public class GlobalOrdinalsStringTermsAggregator extends AbstractStringTermsAggr
         SubAggCollectionMode collectionMode,
         boolean showTermDocCountError,
         CardinalityUpperBound cardinality,
-        Map<String, Object> metadata
+        Map<String, Object> metadata,
+        boolean excludeDeletedDocs
     ) throws IOException {
         super(name, factories, context, parent, order, format, bucketCountThresholds, collectionMode, showTermDocCountError, metadata);
         this.resultStrategy = resultStrategy.apply(this); // ResultStrategy needs a reference to the Aggregator to do its job.
@@ -94,14 +100,14 @@ public class GlobalOrdinalsStringTermsAggregator extends AbstractStringTermsAggr
         this.valueCount = valuesSupplier.get().getValueCount();
         this.acceptedGlobalOrdinals = acceptedOrds;
         if (remapGlobalOrds) {
-            this.collectionStrategy = new RemapGlobalOrds(cardinality);
+            this.collectionStrategy = new RemapGlobalOrds(cardinality, excludeDeletedDocs);
         } else {
             this.collectionStrategy = cardinality.map(estimate -> {
                 if (estimate > 1) {
                     // This is a 500 class error, because we should never be able to reach it.
                     throw new AggregationExecutionException("Dense ords don't know how to collect from many buckets");
                 }
-                return new DenseGlobalOrds();
+                return new DenseGlobalOrds(excludeDeletedDocs);
             });
         }
     }
@@ -278,7 +284,8 @@ public class GlobalOrdinalsStringTermsAggregator extends AbstractStringTermsAggr
             boolean remapGlobalOrds,
             SubAggCollectionMode collectionMode,
             boolean showTermDocCountError,
-            Map<String, Object> metadata
+            Map<String, Object> metadata,
+            boolean excludeDeletedDocs
         ) throws IOException {
             super(
                 name,
@@ -296,7 +303,8 @@ public class GlobalOrdinalsStringTermsAggregator extends AbstractStringTermsAggr
                 collectionMode,
                 showTermDocCountError,
                 CardinalityUpperBound.ONE,
-                metadata
+                metadata,
+                excludeDeletedDocs
             );
             assert factories == null || factories.countAggregators() == 0;
             this.segmentDocCounts = context.bigArrays().newLongArray(1, true);
@@ -445,6 +453,13 @@ public class GlobalOrdinalsStringTermsAggregator extends AbstractStringTermsAggr
      * bucket ordinal.
      */
     class DenseGlobalOrds extends CollectionStrategy {
+
+        private final boolean excludeDeletedDocs;
+
+        DenseGlobalOrds(boolean excludeDeletedDocs) {
+            this.excludeDeletedDocs = excludeDeletedDocs;
+        }
+
         @Override
         String describe() {
             return "dense";
@@ -475,6 +490,14 @@ public class GlobalOrdinalsStringTermsAggregator extends AbstractStringTermsAggr
         @Override
         void forEach(long owningBucketOrd, BucketInfoConsumer consumer) throws IOException {
             assert owningBucketOrd == 0;
+            if (excludeDeletedDocs) {
+                forEachExcludeDeletedDocs(consumer);
+            } else {
+                forEachAllowDeletedDocs(consumer);
+            }
+        }
+
+        private void forEachAllowDeletedDocs(BucketInfoConsumer consumer) throws IOException {
             for (long globalOrd = 0; globalOrd < valueCount; globalOrd++) {
                 if (false == acceptedGlobalOrdinals.test(globalOrd)) {
                     continue;
@@ -486,6 +509,39 @@ public class GlobalOrdinalsStringTermsAggregator extends AbstractStringTermsAggr
             }
         }
 
+        /**
+         * Excludes deleted docs in the results by cross-checking with liveDocs.
+         */
+        private void forEachExcludeDeletedDocs(BucketInfoConsumer consumer) throws IOException {
+            try (LongHash accepted = new LongHash(20, new BigArrays(null, null, ""))) {
+                for (LeafReaderContext ctx : searcher().getTopReaderContext().leaves()) {
+                    LeafReader reader = ctx.reader();
+                    Bits liveDocs = reader.getLiveDocs();
+                    SortedSetDocValues globalOrds = null;
+                    for (int docId = 0; docId < reader.maxDoc(); ++docId) {
+                        if (liveDocs == null || liveDocs.get(docId)) {  // document is not deleted
+                            globalOrds = globalOrds == null ? valuesSource.globalOrdinalsValues(ctx) : globalOrds;
+                            if (globalOrds.advanceExact(docId)) {
+                                for (long globalOrd = globalOrds.nextOrd(); globalOrd != NO_MORE_ORDS; globalOrd = globalOrds.nextOrd()) {
+                                    if (accepted.find(globalOrd) >= 0) {
+                                        continue;
+                                    }
+                                    if (false == acceptedGlobalOrdinals.test(globalOrd)) {
+                                        continue;
+                                    }
+                                    long docCount = bucketDocCount(globalOrd);
+                                    if (bucketCountThresholds.getMinDocCount() == 0 || docCount > 0) {
+                                        consumer.accept(globalOrd, globalOrd, docCount);
+                                        accepted.add(globalOrd);
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+
         @Override
         public void close() {}
     }
@@ -498,9 +554,11 @@ public class GlobalOrdinalsStringTermsAggregator extends AbstractStringTermsAggr
      */
     private class RemapGlobalOrds extends CollectionStrategy {
         private final LongKeyedBucketOrds bucketOrds;
+        private final boolean excludeDeletedDocs;
 
-        private RemapGlobalOrds(CardinalityUpperBound cardinality) {
+        private RemapGlobalOrds(CardinalityUpperBound cardinality, boolean excludeDeletedDocs) {
             bucketOrds = LongKeyedBucketOrds.buildForValueRange(bigArrays(), cardinality, 0, valueCount - 1);
+            this.excludeDeletedDocs = excludeDeletedDocs;
         }
 
         @Override
@@ -534,27 +592,20 @@ public class GlobalOrdinalsStringTermsAggregator extends AbstractStringTermsAggr
 
         @Override
         void forEach(long owningBucketOrd, BucketInfoConsumer consumer) throws IOException {
+            if (excludeDeletedDocs) {
+                forEachExcludeDeletedDocs(owningBucketOrd, consumer);
+            } else {
+                forEachAllowDeletedDocs(owningBucketOrd, consumer);
+            }
+        }
+
+        void forEachAllowDeletedDocs(long owningBucketOrd, BucketInfoConsumer consumer) throws IOException {
             if (bucketCountThresholds.getMinDocCount() == 0) {
                 for (long globalOrd = 0; globalOrd < valueCount; globalOrd++) {
                     if (false == acceptedGlobalOrdinals.test(globalOrd)) {
                         continue;
                     }
-                    /*
-                     * Use `add` instead of `find` here to assign an ordinal
-                     * even if the global ord wasn't found so we can build
-                     * sub-aggregations without trouble even though we haven't
-                     * hit any documents for them. This is wasteful, but
-                     * settings minDocCount == 0 is wasteful in general.....
-                     */
-                    long bucketOrd = bucketOrds.add(owningBucketOrd, globalOrd);
-                    long docCount;
-                    if (bucketOrd < 0) {
-                        bucketOrd = -1 - bucketOrd;
-                        docCount = bucketDocCount(bucketOrd);
-                    } else {
-                        docCount = 0;
-                    }
-                    consumer.accept(globalOrd, bucketOrd, docCount);
+                    addBucketForMinDocCountZero(owningBucketOrd, globalOrd, consumer, null);
                 }
             } else {
                 LongKeyedBucketOrds.BucketOrdsEnum ordsEnum = bucketOrds.ordsEnum(owningBucketOrd);
@@ -567,6 +618,64 @@ public class GlobalOrdinalsStringTermsAggregator extends AbstractStringTermsAggr
             }
         }
 
+        /**
+         * Excludes deleted docs in the results by cross-checking with liveDocs.
+         */
+        void forEachExcludeDeletedDocs(long owningBucketOrd, BucketInfoConsumer consumer) throws IOException {
+            assert bucketCountThresholds.getMinDocCount() == 0;
+            try (LongHash accepted = new LongHash(20, new BigArrays(null, null, ""))) {
+                for (LeafReaderContext ctx : searcher().getTopReaderContext().leaves()) {
+                    LeafReader reader = ctx.reader();
+                    Bits liveDocs = reader.getLiveDocs();
+                    SortedSetDocValues globalOrds = null;
+                    for (int docId = 0; docId < reader.maxDoc(); ++docId) {
+                        if (liveDocs == null || liveDocs.get(docId)) {  // document is not deleted
+                            globalOrds = globalOrds == null ? valuesSource.globalOrdinalsValues(ctx) : globalOrds;
+                            if (globalOrds.advanceExact(docId)) {
+                                for (long globalOrd = globalOrds.nextOrd(); globalOrd != NO_MORE_ORDS; globalOrd = globalOrds.nextOrd()) {
+                                    if (accepted.find(globalOrd) >= 0) {
+                                        continue;
+                                    }
+                                    if (false == acceptedGlobalOrdinals.test(globalOrd)) {
+                                        continue;
+                                    }
+                                    addBucketForMinDocCountZero(owningBucketOrd, globalOrd, consumer, accepted);
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+
+        private void addBucketForMinDocCountZero(
+            long owningBucketOrd,
+            long globalOrd,
+            BucketInfoConsumer consumer,
+            @Nullable LongHash accepted
+        ) throws IOException {
+            /*
+             * Use `add` instead of `find` here to assign an ordinal
+             * even if the global ord wasn't found so we can build
+             * sub-aggregations without trouble even though we haven't
+             * hit any documents for them. This is wasteful, but
+             * settings minDocCount == 0 is wasteful in general.....
+             */
+            long bucketOrd = bucketOrds.add(owningBucketOrd, globalOrd);
+            long docCount;
+            if (bucketOrd < 0) {
+                bucketOrd = -1 - bucketOrd;
+                docCount = bucketDocCount(bucketOrd);
+            } else {
+                docCount = 0;
+            }
+            assert globalOrd >= 0;
+            consumer.accept(globalOrd, bucketOrd, docCount);
+            if (accepted != null) {
+                accepted.add(globalOrd);
+            }
+        }
+
         @Override
         public void close() {
             bucketOrds.close();

+ 11 - 5
server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/MapStringTermsAggregator.java

@@ -52,6 +52,7 @@ public final class MapStringTermsAggregator extends AbstractStringTermsAggregato
     private final ResultStrategy<?, ?> resultStrategy;
     private final BytesKeyedBucketOrds bucketOrds;
     private final IncludeExclude.StringFilter includeExclude;
+    private final boolean excludeDeletedDocs;
 
     public MapStringTermsAggregator(
         String name,
@@ -67,7 +68,8 @@ public final class MapStringTermsAggregator extends AbstractStringTermsAggregato
         SubAggCollectionMode collectionMode,
         boolean showTermDocCountError,
         CardinalityUpperBound cardinality,
-        Map<String, Object> metadata
+        Map<String, Object> metadata,
+        boolean excludeDeletedDocs
     ) throws IOException {
         super(name, factories, context, parent, order, format, bucketCountThresholds, collectionMode, showTermDocCountError, metadata);
         this.resultStrategy = resultStrategy.apply(this); // ResultStrategy needs a reference to the Aggregator to do its job.
@@ -75,6 +77,7 @@ public final class MapStringTermsAggregator extends AbstractStringTermsAggregato
         bucketOrds = BytesKeyedBucketOrds.build(context.bigArrays(), cardinality);
         // set last because if there is an error during construction the collector gets release outside the constructor.
         this.collectorSource = collectorSource;
+        this.excludeDeletedDocs = excludeDeletedDocs;
     }
 
     @Override
@@ -244,7 +247,7 @@ public final class MapStringTermsAggregator extends AbstractStringTermsAggregato
             B[][] topBucketsPerOrd = buildTopBucketsPerOrd(owningBucketOrds.length);
             long[] otherDocCounts = new long[owningBucketOrds.length];
             for (int ordIdx = 0; ordIdx < owningBucketOrds.length; ordIdx++) {
-                collectZeroDocEntriesIfNeeded(owningBucketOrds[ordIdx]);
+                collectZeroDocEntriesIfNeeded(owningBucketOrds[ordIdx], excludeDeletedDocs);
                 int size = (int) Math.min(bucketOrds.size(), bucketCountThresholds.getShardSize());
 
                 PriorityQueue<B> ordered = buildPriorityQueue(size);
@@ -296,7 +299,7 @@ public final class MapStringTermsAggregator extends AbstractStringTermsAggregato
          * Collect extra entries for "zero" hit documents if they were requested
          * and required.
          */
-        abstract void collectZeroDocEntriesIfNeeded(long owningBucketOrd) throws IOException;
+        abstract void collectZeroDocEntriesIfNeeded(long owningBucketOrd, boolean excludeDeletedDocs) throws IOException;
 
         /**
          * Build an empty temporary bucket.
@@ -371,7 +374,7 @@ public final class MapStringTermsAggregator extends AbstractStringTermsAggregato
         }
 
         @Override
-        void collectZeroDocEntriesIfNeeded(long owningBucketOrd) throws IOException {
+        void collectZeroDocEntriesIfNeeded(long owningBucketOrd, boolean excludeDeletedDocs) throws IOException {
             if (bucketCountThresholds.getMinDocCount() != 0) {
                 return;
             }
@@ -383,6 +386,9 @@ public final class MapStringTermsAggregator extends AbstractStringTermsAggregato
                 SortedBinaryDocValues values = valuesSource.bytesValues(ctx);
                 // brute force
                 for (int docId = 0; docId < ctx.reader().maxDoc(); ++docId) {
+                    if (excludeDeletedDocs && ctx.reader().getLiveDocs() != null && ctx.reader().getLiveDocs().get(docId) == false) {
+                        continue;
+                    }
                     if (values.advanceExact(docId)) {
                         int valueCount = values.docValueCount();
                         for (int i = 0; i < valueCount; ++i) {
@@ -519,7 +525,7 @@ public final class MapStringTermsAggregator extends AbstractStringTermsAggregato
         }
 
         @Override
-        void collectZeroDocEntriesIfNeeded(long owningBucketOrd) throws IOException {}
+        void collectZeroDocEntriesIfNeeded(long owningBucketOrd, boolean excludeDeletedDocs) throws IOException {}
 
         @Override
         Supplier<SignificantStringTerms.Bucket> emptyBucketBuilder(long owningBucketOrd) {

+ 11 - 5
server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/NumericTermsAggregator.java

@@ -50,6 +50,7 @@ public final class NumericTermsAggregator extends TermsAggregator {
     private final ValuesSource.Numeric valuesSource;
     private final LongKeyedBucketOrds bucketOrds;
     private final LongFilter longFilter;
+    private final boolean excludeDeletedDocs;
 
     public NumericTermsAggregator(
         String name,
@@ -64,13 +65,15 @@ public final class NumericTermsAggregator extends TermsAggregator {
         SubAggCollectionMode subAggCollectMode,
         IncludeExclude.LongFilter longFilter,
         CardinalityUpperBound cardinality,
-        Map<String, Object> metadata
+        Map<String, Object> metadata,
+        boolean excludeDeletedDocs
     ) throws IOException {
         super(name, factories, context, parent, bucketCountThresholds, order, format, subAggCollectMode, metadata);
         this.resultStrategy = resultStrategy.apply(this); // ResultStrategy needs a reference to the Aggregator to do its job.
         this.valuesSource = valuesSource;
         this.longFilter = longFilter;
         bucketOrds = LongKeyedBucketOrds.build(bigArrays(), cardinality);
+        this.excludeDeletedDocs = excludeDeletedDocs;
     }
 
     @Override
@@ -144,7 +147,7 @@ public final class NumericTermsAggregator extends TermsAggregator {
             B[][] topBucketsPerOrd = buildTopBucketsPerOrd(owningBucketOrds.length);
             long[] otherDocCounts = new long[owningBucketOrds.length];
             for (int ordIdx = 0; ordIdx < owningBucketOrds.length; ordIdx++) {
-                collectZeroDocEntriesIfNeeded(owningBucketOrds[ordIdx]);
+                collectZeroDocEntriesIfNeeded(owningBucketOrds[ordIdx], excludeDeletedDocs);
                 long bucketsInOrd = bucketOrds.bucketsInOrd(owningBucketOrds[ordIdx]);
 
                 int size = (int) Math.min(bucketsInOrd, bucketCountThresholds.getShardSize());
@@ -240,7 +243,7 @@ public final class NumericTermsAggregator extends TermsAggregator {
          * Collect extra entries for "zero" hit documents if they were requested
          * and required.
          */
-        abstract void collectZeroDocEntriesIfNeeded(long owningBucketOrd) throws IOException;
+        abstract void collectZeroDocEntriesIfNeeded(long owningBucketOrd, boolean excludeDeletedDocs) throws IOException;
 
         /**
          * Turn the buckets into an aggregation result.
@@ -285,7 +288,7 @@ public final class NumericTermsAggregator extends TermsAggregator {
         abstract B buildEmptyBucket();
 
         @Override
-        final void collectZeroDocEntriesIfNeeded(long owningBucketOrd) throws IOException {
+        final void collectZeroDocEntriesIfNeeded(long owningBucketOrd, boolean excludeDeletedDocs) throws IOException {
             if (bucketCountThresholds.getMinDocCount() != 0) {
                 return;
             }
@@ -296,6 +299,9 @@ public final class NumericTermsAggregator extends TermsAggregator {
             for (LeafReaderContext ctx : searcher().getTopReaderContext().leaves()) {
                 SortedNumericDocValues values = getValues(ctx);
                 for (int docId = 0; docId < ctx.reader().maxDoc(); ++docId) {
+                    if (excludeDeletedDocs && ctx.reader().getLiveDocs() != null && ctx.reader().getLiveDocs().get(docId) == false) {
+                        continue;
+                    }
                     if (values.advanceExact(docId)) {
                         int valueCount = values.docValueCount();
                         for (int v = 0; v < valueCount; ++v) {
@@ -561,7 +567,7 @@ public final class NumericTermsAggregator extends TermsAggregator {
         }
 
         @Override
-        void collectZeroDocEntriesIfNeeded(long owningBucketOrd) throws IOException {}
+        void collectZeroDocEntriesIfNeeded(long owningBucketOrd, boolean excludeDeletedDocs) throws IOException {}
 
         @Override
         SignificantLongTerms buildResult(long owningBucketOrd, long otherDocCoun, SignificantLongTerms.Bucket[] topBuckets) {

+ 6 - 3
server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/SignificantTermsAggregatorFactory.java

@@ -197,7 +197,8 @@ public class SignificantTermsAggregatorFactory extends ValuesSourceAggregatorFac
                 SubAggCollectionMode.BREADTH_FIRST,
                 longFilter,
                 cardinality,
-                metadata
+                metadata,
+                false
             );
         };
     }
@@ -356,7 +357,8 @@ public class SignificantTermsAggregatorFactory extends ValuesSourceAggregatorFac
                     SubAggCollectionMode.BREADTH_FIRST,
                     false,
                     cardinality,
-                    metadata
+                    metadata,
+                    false
                 );
 
             }
@@ -409,7 +411,8 @@ public class SignificantTermsAggregatorFactory extends ValuesSourceAggregatorFac
                     SubAggCollectionMode.BREADTH_FIRST,
                     false,
                     cardinality,
-                    metadata
+                    metadata,
+                    false
                 );
             }
         };

+ 2 - 1
server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/SignificantTextAggregatorFactory.java

@@ -190,7 +190,8 @@ public class SignificantTextAggregatorFactory extends AggregatorFactory {
                 SubAggCollectionMode.BREADTH_FIRST,
                 false,
                 cardinality,
-                metadata
+                metadata,
+                false
             );
             success = true;
             return mapStringTermsAggregator;

+ 27 - 3
server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/TermsAggregationBuilder.java

@@ -37,6 +37,8 @@ import java.util.Map;
 import java.util.Objects;
 import java.util.function.ToLongFunction;
 
+import static org.elasticsearch.TransportVersions.AGGS_EXCLUDED_DELETED_DOCS;
+
 public class TermsAggregationBuilder extends ValuesSourceAggregationBuilder<TermsAggregationBuilder> {
     public static final int KEY_ORDER_CONCURRENCY_THRESHOLD = 50;
 
@@ -112,6 +114,7 @@ public class TermsAggregationBuilder extends ValuesSourceAggregationBuilder<Term
     private final TermsAggregator.BucketCountThresholds bucketCountThresholds;
 
     private boolean showTermDocCountError = false;
+    private boolean excludeDeletedDocs = false;
 
     public TermsAggregationBuilder(String name) {
         super(name);
@@ -195,6 +198,9 @@ public class TermsAggregationBuilder extends ValuesSourceAggregationBuilder<Term
         includeExclude = in.readOptionalWriteable(IncludeExclude::new);
         order = InternalOrder.Streams.readOrder(in);
         showTermDocCountError = in.readBoolean();
+        if (in.getTransportVersion().onOrAfter(AGGS_EXCLUDED_DELETED_DOCS)) {
+            excludeDeletedDocs = in.readBoolean();
+        }
     }
 
     @Override
@@ -210,6 +216,9 @@ public class TermsAggregationBuilder extends ValuesSourceAggregationBuilder<Term
         out.writeOptionalWriteable(includeExclude);
         order.writeTo(out);
         out.writeBoolean(showTermDocCountError);
+        if (out.getTransportVersion().onOrAfter(AGGS_EXCLUDED_DELETED_DOCS)) {
+            out.writeBoolean(excludeDeletedDocs);
+        }
     }
 
     /**
@@ -391,6 +400,18 @@ public class TermsAggregationBuilder extends ValuesSourceAggregationBuilder<Term
         return this;
     }
 
+    /**
+     * Set whether deleted documents should be explicitly excluded from the aggregation results
+     */
+    public TermsAggregationBuilder excludeDeletedDocs(boolean excludeDeletedDocs) {
+        this.excludeDeletedDocs = excludeDeletedDocs;
+        return this;
+    }
+
+    public boolean excludeDeletedDocs() {
+        return excludeDeletedDocs;
+    }
+
     @Override
     public BucketCardinality bucketCardinality() {
         return BucketCardinality.MANY;
@@ -417,7 +438,8 @@ public class TermsAggregationBuilder extends ValuesSourceAggregationBuilder<Term
             parent,
             subFactoriesBuilder,
             metadata,
-            aggregatorSupplier
+            aggregatorSupplier,
+            excludeDeletedDocs
         );
     }
 
@@ -448,7 +470,8 @@ public class TermsAggregationBuilder extends ValuesSourceAggregationBuilder<Term
             executionHint,
             includeExclude,
             order,
-            showTermDocCountError
+            showTermDocCountError,
+            excludeDeletedDocs
         );
     }
 
@@ -463,7 +486,8 @@ public class TermsAggregationBuilder extends ValuesSourceAggregationBuilder<Term
             && Objects.equals(executionHint, other.executionHint)
             && Objects.equals(includeExclude, other.includeExclude)
             && Objects.equals(order, other.order)
-            && Objects.equals(showTermDocCountError, other.showTermDocCountError);
+            && Objects.equals(showTermDocCountError, other.showTermDocCountError)
+            && Objects.equals(excludeDeletedDocs, other.excludeDeletedDocs);
     }
 
     @Override

+ 28 - 13
server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/TermsAggregatorFactory.java

@@ -102,7 +102,8 @@ public class TermsAggregatorFactory extends ValuesSourceAggregatorFactory {
             subAggCollectMode,
             showTermDocCountError,
             cardinality,
-            metadata) -> {
+            metadata,
+            excludeDeletedDocs) -> {
             ValuesSource valuesSource = valuesSourceConfig.getValuesSource();
             ExecutionMode execution = null;
             if (executionHint != null) {
@@ -145,7 +146,8 @@ public class TermsAggregatorFactory extends ValuesSourceAggregatorFactory {
                 subAggCollectMode,
                 showTermDocCountError,
                 cardinality,
-                metadata
+                metadata,
+                excludeDeletedDocs
             );
         };
     }
@@ -168,7 +170,8 @@ public class TermsAggregatorFactory extends ValuesSourceAggregatorFactory {
             subAggCollectMode,
             showTermDocCountError,
             cardinality,
-            metadata) -> {
+            metadata,
+            excludeDeletedDocs) -> {
 
             if ((includeExclude != null) && (includeExclude.isRegexBased())) {
                 throw new IllegalArgumentException(
@@ -211,7 +214,8 @@ public class TermsAggregatorFactory extends ValuesSourceAggregatorFactory {
                 subAggCollectMode,
                 longFilter,
                 cardinality,
-                metadata
+                metadata,
+                excludeDeletedDocs
             );
         };
     }
@@ -223,6 +227,7 @@ public class TermsAggregatorFactory extends ValuesSourceAggregatorFactory {
     private final SubAggCollectionMode collectMode;
     private final TermsAggregator.BucketCountThresholds bucketCountThresholds;
     private final boolean showTermDocCountError;
+    private final boolean excludeDeletedDocs;
 
     TermsAggregatorFactory(
         String name,
@@ -237,7 +242,8 @@ public class TermsAggregatorFactory extends ValuesSourceAggregatorFactory {
         AggregatorFactory parent,
         AggregatorFactories.Builder subFactoriesBuilder,
         Map<String, Object> metadata,
-        TermsAggregatorSupplier aggregatorSupplier
+        TermsAggregatorSupplier aggregatorSupplier,
+        boolean excludeDeletedDocs
     ) throws IOException {
         super(name, config, context, parent, subFactoriesBuilder, metadata);
         this.aggregatorSupplier = aggregatorSupplier;
@@ -247,6 +253,7 @@ public class TermsAggregatorFactory extends ValuesSourceAggregatorFactory {
         this.collectMode = collectMode;
         this.bucketCountThresholds = bucketCountThresholds;
         this.showTermDocCountError = showTermDocCountError;
+        this.excludeDeletedDocs = excludeDeletedDocs;
     }
 
     @Override
@@ -325,7 +332,8 @@ public class TermsAggregatorFactory extends ValuesSourceAggregatorFactory {
             collectMode,
             showTermDocCountError,
             cardinality,
-            metadata
+            metadata,
+            excludeDeletedDocs
         );
     }
 
@@ -384,7 +392,8 @@ public class TermsAggregatorFactory extends ValuesSourceAggregatorFactory {
                 SubAggCollectionMode subAggCollectMode,
                 boolean showTermDocCountError,
                 CardinalityUpperBound cardinality,
-                Map<String, Object> metadata
+                Map<String, Object> metadata,
+                boolean excludeDeletedDocs
             ) throws IOException {
                 IncludeExclude.StringFilter filter = includeExclude == null
                     ? null
@@ -403,7 +412,8 @@ public class TermsAggregatorFactory extends ValuesSourceAggregatorFactory {
                     subAggCollectMode,
                     showTermDocCountError,
                     cardinality,
-                    metadata
+                    metadata,
+                    excludeDeletedDocs
                 );
             }
         },
@@ -422,7 +432,8 @@ public class TermsAggregatorFactory extends ValuesSourceAggregatorFactory {
                 SubAggCollectionMode subAggCollectMode,
                 boolean showTermDocCountError,
                 CardinalityUpperBound cardinality,
-                Map<String, Object> metadata
+                Map<String, Object> metadata,
+                boolean excludeDeletedDocs
             ) throws IOException {
 
                 assert valuesSourceConfig.getValuesSource() instanceof ValuesSource.Bytes.WithOrdinals;
@@ -433,7 +444,8 @@ public class TermsAggregatorFactory extends ValuesSourceAggregatorFactory {
                 if (maxOrd > 0
                     && maxOrd <= MAX_ORDS_TO_TRY_FILTERS
                     && context.enableRewriteToFilterByFilter()
-                    && false == context.isInSortOrderExecutionRequired()) {
+                    && false == context.isInSortOrderExecutionRequired()
+                    && false == excludeDeletedDocs) {
                     StringTermsAggregatorFromFilters adapted = StringTermsAggregatorFromFilters.adaptIntoFiltersOrNull(
                         name,
                         factories,
@@ -501,7 +513,8 @@ public class TermsAggregatorFactory extends ValuesSourceAggregatorFactory {
                         false,
                         subAggCollectMode,
                         showTermDocCountError,
-                        metadata
+                        metadata,
+                        excludeDeletedDocs
                     );
 
                 }
@@ -547,7 +560,8 @@ public class TermsAggregatorFactory extends ValuesSourceAggregatorFactory {
                     subAggCollectMode,
                     showTermDocCountError,
                     cardinality,
-                    metadata
+                    metadata,
+                    excludeDeletedDocs
                 );
             }
         };
@@ -580,7 +594,8 @@ public class TermsAggregatorFactory extends ValuesSourceAggregatorFactory {
             SubAggCollectionMode subAggCollectMode,
             boolean showTermDocCountError,
             CardinalityUpperBound cardinality,
-            Map<String, Object> metadata
+            Map<String, Object> metadata,
+            boolean excludeDeletedDocs
         ) throws IOException;
 
         @Override

+ 2 - 1
server/src/main/java/org/elasticsearch/search/aggregations/bucket/terms/TermsAggregatorSupplier.java

@@ -31,6 +31,7 @@ interface TermsAggregatorSupplier {
         Aggregator.SubAggCollectionMode subAggCollectMode,
         boolean showTermDocCountError,
         CardinalityUpperBound cardinality,
-        Map<String, Object> metadata
+        Map<String, Object> metadata,
+        boolean excludeDeletedDocs
     ) throws IOException;
 }

+ 37 - 0
server/src/test/java/org/elasticsearch/search/aggregations/AggregatorFactoriesBuilderTests.java

@@ -13,6 +13,7 @@ import org.elasticsearch.common.io.stream.Writeable.Reader;
 import org.elasticsearch.common.settings.Settings;
 import org.elasticsearch.search.SearchModule;
 import org.elasticsearch.search.aggregations.AggregatorFactories.Builder;
+import org.elasticsearch.search.aggregations.bucket.terms.TermsAggregationBuilder;
 import org.elasticsearch.search.aggregations.pipeline.CumulativeSumPipelineAggregationBuilder;
 import org.elasticsearch.test.AbstractXContentSerializingTestCase;
 import org.elasticsearch.xcontent.NamedXContentRegistry;
@@ -128,6 +129,42 @@ public class AggregatorFactoriesBuilderTests extends AbstractXContentSerializing
         assertNotEquals(builder1.hashCode(), builder2.hashCode());
     }
 
+    public void testForceExcludedDocs() {
+        // simple
+        AggregatorFactories.Builder builder = new AggregatorFactories.Builder();
+        TermsAggregationBuilder termsAggregationBuilder = AggregationBuilders.terms("myterms");
+        builder.addAggregator(termsAggregationBuilder);
+        assertFalse(termsAggregationBuilder.excludeDeletedDocs());
+        assertFalse(builder.hasZeroMinDocTermsAggregation());
+        termsAggregationBuilder.minDocCount(0);
+        assertTrue(builder.hasZeroMinDocTermsAggregation());
+        builder.forceTermsAggsToExcludeDeletedDocs();
+        assertTrue(termsAggregationBuilder.excludeDeletedDocs());
+
+        // nested
+        AggregatorFactories.Builder nested = new AggregatorFactories.Builder();
+        boolean hasZeroMinDocTermsAggregation = false;
+        for (int i = 0; i <= randomIntBetween(1, 10); i++) {
+            AggregationBuilder agg = getRandomAggregation();
+            nested.addAggregator(agg);
+            if (randomBoolean()) {
+                hasZeroMinDocTermsAggregation = true;
+                agg.subAggregation(termsAggregationBuilder);
+            }
+        }
+        if (hasZeroMinDocTermsAggregation) {
+            assertTrue(nested.hasZeroMinDocTermsAggregation());
+            nested.forceTermsAggsToExcludeDeletedDocs();
+            for (AggregationBuilder agg : nested.getAggregatorFactories()) {
+                if (agg instanceof TermsAggregationBuilder) {
+                    assertTrue(((TermsAggregationBuilder) agg).excludeDeletedDocs());
+                }
+            }
+        } else {
+            assertFalse(nested.hasZeroMinDocTermsAggregation());
+        }
+    }
+
     private static AggregationBuilder getRandomAggregation() {
         // just a couple of aggregations, sufficient for the purpose of this test
         final int randomAggregatorPoolSize = 4;

+ 128 - 1
x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/integration/DocumentLevelSecurityTests.java

@@ -48,6 +48,9 @@ import org.elasticsearch.rest.RestStatus;
 import org.elasticsearch.search.SearchHit;
 import org.elasticsearch.search.aggregations.AggregationBuilders;
 import org.elasticsearch.search.aggregations.bucket.global.Global;
+import org.elasticsearch.search.aggregations.bucket.terms.IncludeExclude;
+import org.elasticsearch.search.aggregations.bucket.terms.LongTerms;
+import org.elasticsearch.search.aggregations.bucket.terms.StringTerms;
 import org.elasticsearch.search.aggregations.bucket.terms.Terms;
 import org.elasticsearch.search.builder.PointInTimeBuilder;
 import org.elasticsearch.search.profile.ProfileResult;
@@ -101,9 +104,12 @@ import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertSear
 import static org.elasticsearch.xcontent.XContentFactory.jsonBuilder;
 import static org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken.BASIC_AUTH_HEADER;
 import static org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken.basicAuthHeaderValue;
+import static org.hamcrest.Matchers.allOf;
+import static org.hamcrest.Matchers.containsString;
 import static org.hamcrest.Matchers.equalTo;
 import static org.hamcrest.Matchers.instanceOf;
 import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.not;
 import static org.hamcrest.Matchers.notNullValue;
 
 @LuceneTestCase.SuppressCodecs("*") // suppress test codecs otherwise test using completion suggester fails
@@ -134,7 +140,8 @@ public class DocumentLevelSecurityTests extends SecurityIntegTestCase {
             user3:%s
             user4:%s
             user5:%s
-            """, usersPasswdHashed, usersPasswdHashed, usersPasswdHashed, usersPasswdHashed, usersPasswdHashed);
+            user6:%s
+            """, usersPasswdHashed, usersPasswdHashed, usersPasswdHashed, usersPasswdHashed, usersPasswdHashed, usersPasswdHashed);
     }
 
     @Override
@@ -145,6 +152,7 @@ public class DocumentLevelSecurityTests extends SecurityIntegTestCase {
             role3:user2,user3
             role4:user4
             role5:user5
+            role6:user6
             """;
     }
 
@@ -192,6 +200,12 @@ public class DocumentLevelSecurityTests extends SecurityIntegTestCase {
                   privileges: [ read ]
                   field_security:
                      grant: [ 'field1', 'other_field', 'suggest_field2' ]
+            role6:
+              cluster: [ all ]
+              indices:
+                - names: '*'
+                  privileges: [ ALL ]
+                  query: '{"term" : {"color" : "red"}}'
             """;
     }
 
@@ -988,6 +1002,119 @@ public class DocumentLevelSecurityTests extends SecurityIntegTestCase {
         );
     }
 
+    public void testZeroMinDocAggregation() throws Exception {
+        assertAcked(
+            indicesAdmin().prepareCreate("test")
+                .setMapping("color", "type=keyword", "fruit", "type=keyword", "count", "type=integer")
+                .setSettings(Map.of("index.number_of_shards", 1))
+        );
+        prepareIndex("test").setId("1").setSource("color", "red", "fruit", "apple", "count", -1).setRefreshPolicy(IMMEDIATE).get();
+        prepareIndex("test").setId("2").setSource("color", "yellow", "fruit", "banana", "count", -2).setRefreshPolicy(IMMEDIATE).get();
+        prepareIndex("test").setId("3").setSource("color", "green", "fruit", "grape", "count", -3).setRefreshPolicy(IMMEDIATE).get();
+        prepareIndex("test").setId("4").setSource("color", "red", "fruit", "grape", "count", -4).setRefreshPolicy(IMMEDIATE).get();
+        indicesAdmin().prepareForceMerge("test").get();
+
+        assertResponse(
+            client().filterWithHeader(Collections.singletonMap(BASIC_AUTH_HEADER, basicAuthHeaderValue("user6", USERS_PASSWD)))
+                .prepareSearch("test")
+                .setQuery(termQuery("fruit", "apple"))
+                // global ordinal
+                .addAggregation(AggregationBuilders.terms("colors1").field("color").minDocCount(0))
+                .addAggregation(AggregationBuilders.terms("fruits").field("fruit").minDocCount(0))
+                // global ordinal remapped
+                .addAggregation(
+                    AggregationBuilders.terms("colors2")
+                        .field("color")
+                        .minDocCount(0)
+                        .includeExclude(new IncludeExclude(".*", null, null, null))
+                )
+                // mapped
+                .addAggregation(AggregationBuilders.terms("colors3").field("color").minDocCount(0).executionHint("map"))
+                // numeric
+                .addAggregation(AggregationBuilders.terms("counts").field("count").minDocCount(0))
+                // nested
+                .addAggregation(
+                    AggregationBuilders.terms("nested")
+                        .field("color")
+                        .minDocCount(0)
+                        .subAggregation(
+                            AggregationBuilders.terms("fruits")
+                                .field("fruit")
+                                .minDocCount(0)
+                                .executionHint("map")
+                                .subAggregation(AggregationBuilders.terms("counts").field("count").minDocCount(0))
+                        )
+                        .minDocCount(0)
+                ),
+            response -> {
+                assertThat(
+                    response.toString(),
+                    allOf(
+                        containsString("apple"),
+                        containsString("grape"),
+                        containsString("red"),
+                        containsString("-1"),
+                        containsString("-4")
+                    )
+                );
+                assertThat(
+                    response.toString(),
+                    allOf(
+                        not(containsString("banana")),
+                        not(containsString("yellow")),
+                        not(containsString("green")),
+                        not(containsString("-2")),
+                        not(containsString("-3"))
+                    )
+                );
+                assertHitCount(response, 1);
+                assertSearchHits(response, "1");
+                // fruits
+                StringTerms fruits = response.getAggregations().get("fruits");
+                assertThat(fruits.getBuckets().size(), equalTo(2));
+                List<StringTerms.Bucket> fruitBuckets = fruits.getBuckets();
+                assertTrue(fruitBuckets.stream().anyMatch(bucket -> bucket.getKeyAsString().equals("apple") && bucket.getDocCount() == 1));
+                assertTrue(fruitBuckets.stream().anyMatch(bucket -> bucket.getKeyAsString().equals("grape") && bucket.getDocCount() == 0));
+                // counts
+                LongTerms counts = response.getAggregations().get("counts");
+                assertThat(counts.getBuckets().size(), equalTo(2));
+                List<LongTerms.Bucket> countsBuckets = counts.getBuckets();
+                assertTrue(countsBuckets.stream().anyMatch(bucket -> bucket.getKeyAsString().equals("-1") && bucket.getDocCount() == 1));
+                assertTrue(countsBuckets.stream().anyMatch(bucket -> bucket.getKeyAsString().equals("-4") && bucket.getDocCount() == 0));
+                // colors
+                for (int i = 1; i <= 3; i++) {
+                    StringTerms colors = response.getAggregations().get("colors" + i);
+                    assertThat(colors.getBuckets().size(), equalTo(1));
+                    assertThat(colors.getBuckets().get(0).getKeyAsString(), equalTo("red"));
+                    assertThat(colors.getBuckets().get(0).getDocCount(), equalTo(1L));
+                }
+                // nested
+                StringTerms nested = response.getAggregations().get("nested");
+                assertThat(nested.getBuckets().size(), equalTo(1));
+                assertThat(nested.getBuckets().get(0).getKeyAsString(), equalTo("red"));
+                assertThat(nested.getBuckets().get(0).getDocCount(), equalTo(1L));
+                StringTerms innerFruits = nested.getBuckets().get(0).getAggregations().get("fruits");
+                List<StringTerms.Bucket> innerFruitsBuckets = innerFruits.getBuckets();
+                assertTrue(innerFruitsBuckets.stream().anyMatch(b -> b.getKeyAsString().equals("apple") && b.getDocCount() == 1));
+                assertTrue(innerFruitsBuckets.stream().anyMatch(b -> b.getKeyAsString().equals("grape") && b.getDocCount() == 0));
+                assertThat(innerFruitsBuckets.size(), equalTo(2));
+
+                for (int i = 0; i <= 1; i++) {
+                    String parentBucketKey = innerFruitsBuckets.get(i).getKeyAsString();
+                    LongTerms innerCounts = innerFruitsBuckets.get(i).getAggregations().get("counts");
+                    assertThat(innerCounts.getBuckets().size(), equalTo(2));
+                    List<LongTerms.Bucket> icb = innerCounts.getBuckets();
+                    if ("apple".equals(parentBucketKey)) {
+                        assertTrue(icb.stream().anyMatch(bucket -> bucket.getKeyAsString().equals("-1") && bucket.getDocCount() == 1));
+                    } else {
+                        assertTrue(icb.stream().anyMatch(bucket -> bucket.getKeyAsString().equals("-1") && bucket.getDocCount() == 0));
+                    }
+                    assertTrue(icb.stream().anyMatch(bucket -> bucket.getKeyAsString().equals("-4") && bucket.getDocCount() == 0));
+                }
+            }
+        );
+    }
+
     public void testParentChild() throws Exception {
         XContentBuilder mapping = jsonBuilder().startObject()
             .startObject("properties")

+ 12 - 1
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/interceptor/SearchRequestInterceptor.java

@@ -50,6 +50,11 @@ public class SearchRequestInterceptor extends FieldAndDocumentLevelSecurityReque
                     )
                 );
             } else {
+                if (hasZeroMinDocTermsAggregation(request)) {
+                    assert request.source() != null && request.source().aggregations() != null;
+                    request.source().aggregations().forceTermsAggsToExcludeDeletedDocs();
+                }
+
                 listener.onResponse(null);
             }
         } else {
@@ -60,7 +65,7 @@ public class SearchRequestInterceptor extends FieldAndDocumentLevelSecurityReque
     @Override
     public boolean supports(IndicesRequest request) {
         if (request instanceof SearchRequest searchRequest) {
-            return hasSuggest(searchRequest) || hasProfile(searchRequest);
+            return hasSuggest(searchRequest) || hasProfile(searchRequest) || hasZeroMinDocTermsAggregation(searchRequest);
         } else {
             return false;
         }
@@ -74,4 +79,10 @@ public class SearchRequestInterceptor extends FieldAndDocumentLevelSecurityReque
         return searchRequest.source() != null && searchRequest.source().profile();
     }
 
+    private static boolean hasZeroMinDocTermsAggregation(SearchRequest searchRequest) {
+        return searchRequest.source() != null
+            && searchRequest.source().aggregations() != null
+            && searchRequest.source().aggregations().hasZeroMinDocTermsAggregation();
+    }
+
 }

+ 105 - 0
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/interceptor/SearchRequestInterceptorTests.java

@@ -0,0 +1,105 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.security.authz.interceptor;
+
+import org.elasticsearch.action.search.SearchRequest;
+import org.elasticsearch.action.support.PlainActionFuture;
+import org.elasticsearch.cluster.service.ClusterService;
+import org.elasticsearch.common.bytes.BytesArray;
+import org.elasticsearch.license.MockLicenseState;
+import org.elasticsearch.search.aggregations.AggregationBuilders;
+import org.elasticsearch.search.aggregations.bucket.terms.TermsAggregationBuilder;
+import org.elasticsearch.search.builder.SearchSourceBuilder;
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.threadpool.TestThreadPool;
+import org.elasticsearch.threadpool.ThreadPool;
+import org.elasticsearch.xpack.core.security.authz.accesscontrol.IndicesAccessControl;
+import org.elasticsearch.xpack.core.security.authz.permission.DocumentPermissions;
+import org.elasticsearch.xpack.core.security.authz.permission.FieldPermissions;
+import org.junit.After;
+import org.junit.Before;
+
+import java.util.Map;
+import java.util.Set;
+
+import static org.elasticsearch.xpack.core.security.SecurityField.DOCUMENT_LEVEL_SECURITY_FEATURE;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class SearchRequestInterceptorTests extends ESTestCase {
+
+    private ClusterService clusterService;
+    private ThreadPool threadPool;
+    private MockLicenseState licenseState;
+    private SearchRequestInterceptor interceptor;
+
+    @Before
+    public void init() {
+        threadPool = new TestThreadPool("search request interceptor tests");
+        licenseState = mock(MockLicenseState.class);
+        when(licenseState.isAllowed(DOCUMENT_LEVEL_SECURITY_FEATURE)).thenReturn(true);
+        clusterService = mock(ClusterService.class);
+        interceptor = new SearchRequestInterceptor(threadPool, licenseState, clusterService);
+    }
+
+    @After
+    public void stopThreadPool() {
+        terminate(threadPool);
+    }
+
+    public void testForceExcludeDeletedDocs() {
+        SearchRequest searchRequest = new SearchRequest();
+        SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
+        TermsAggregationBuilder termsAggregationBuilder = AggregationBuilders.terms("myterms");
+        termsAggregationBuilder.minDocCount(0);
+        searchSourceBuilder.aggregation(termsAggregationBuilder);
+        searchRequest.source(searchSourceBuilder);
+
+        final DocumentPermissions documentPermissions = DocumentPermissions.filteredBy(Set.of(new BytesArray("""
+            {"term":{"username":"foo"}}""")));
+        final String index = randomAlphaOfLengthBetween(3, 8);
+        final PlainActionFuture<Void> listener = new PlainActionFuture<>();
+        assertFalse(termsAggregationBuilder.excludeDeletedDocs());
+        interceptor.disableFeatures(
+            searchRequest,
+            Map.of(index, new IndicesAccessControl.IndexAccessControl(FieldPermissions.DEFAULT, documentPermissions)),
+            listener
+        );
+        assertTrue(termsAggregationBuilder.excludeDeletedDocs()); // changed value
+    }
+
+    public void testNoForceExcludeDeletedDocs() {
+        SearchRequest searchRequest = new SearchRequest();
+        SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
+        TermsAggregationBuilder termsAggregationBuilder = new TermsAggregationBuilder("myterms");
+        termsAggregationBuilder.minDocCount(1);
+        searchSourceBuilder.aggregation(termsAggregationBuilder);
+        searchRequest.source(searchSourceBuilder);
+
+        final DocumentPermissions documentPermissions = DocumentPermissions.filteredBy(Set.of(new BytesArray("""
+            {"term":{"username":"foo"}}""")));
+        final String index = randomAlphaOfLengthBetween(3, 8);
+        final PlainActionFuture<Void> listener = new PlainActionFuture<>();
+        assertFalse(termsAggregationBuilder.excludeDeletedDocs());
+        interceptor.disableFeatures(
+            searchRequest,
+            Map.of(index, new IndicesAccessControl.IndexAccessControl(FieldPermissions.DEFAULT, documentPermissions)),
+            listener
+        );
+        assertFalse(termsAggregationBuilder.excludeDeletedDocs()); // did not change value
+
+        termsAggregationBuilder.minDocCount(0);
+        interceptor.disableFeatures(
+            searchRequest,
+            Map.of(), // no DLS
+            listener
+        );
+        assertFalse(termsAggregationBuilder.excludeDeletedDocs()); // did not change value
+    }
+
+}