Skip to content

Commit 1465b99

Browse files
authored
Support intersecting multi-sets of queries with DocumentPermissions (#91151)
Since #81403, the Role class has been able to support multi-levels of limiting (intersections). However, it was an oversight that the underlying DocumentPermissions and FieldPermissions still do not support it. They are still hardcoded to support up to 2 levels of intersection. This PR now updates DocumentPermissions so it can support multi-level of intersections. The similar change for FieldPermissions will be done in a separate PR.
1 parent a7d91d8 commit 1465b99

File tree

5 files changed

+136
-154
lines changed

5 files changed

+136
-154
lines changed

x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/permission/DocumentPermissions.java

Lines changed: 58 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import org.elasticsearch.common.bytes.BytesReference;
1515
import org.elasticsearch.common.io.stream.StreamOutput;
1616
import org.elasticsearch.common.lucene.search.Queries;
17+
import org.elasticsearch.core.Nullable;
1718
import org.elasticsearch.index.mapper.NestedLookup;
1819
import org.elasticsearch.index.query.QueryBuilder;
1920
import org.elasticsearch.index.query.QueryRewriteContext;
@@ -36,6 +37,7 @@
3637
import java.util.SortedSet;
3738
import java.util.TreeSet;
3839
import java.util.function.Function;
40+
import java.util.stream.Stream;
3941

4042
import static org.apache.lucene.search.BooleanClause.Occur.FILTER;
4143
import static org.apache.lucene.search.BooleanClause.Occur.SHOULD;
@@ -46,58 +48,56 @@
4648
* queries are used as an additional filter.
4749
*/
4850
public final class DocumentPermissions implements CacheKey {
49-
// SortedSet because orders are important when they get serialised for request cache key
50-
private final SortedSet<BytesReference> queries;
51-
private final SortedSet<BytesReference> limitedByQueries;
52-
private List<String> evaluatedQueries;
53-
private List<String> evaluatedLimitedByQueries;
5451

55-
private static DocumentPermissions ALLOW_ALL = new DocumentPermissions();
52+
@Nullable
53+
private final List<Set<BytesReference>> listOfQueries;
54+
@Nullable
55+
private List<List<String>> listOfEvaluatedQueries;
5656

57-
DocumentPermissions() {
58-
this.queries = null;
59-
this.limitedByQueries = null;
57+
private static final DocumentPermissions ALLOW_ALL = new DocumentPermissions();
58+
59+
private DocumentPermissions() {
60+
this.listOfQueries = null;
6061
}
6162

62-
DocumentPermissions(Set<BytesReference> queries) {
63-
this(queries, null);
63+
private DocumentPermissions(Set<BytesReference> queries) {
64+
assert queries != null && false == queries.isEmpty() : "null or empty queries not permitted";
65+
this.listOfQueries = List.of(new TreeSet<>(queries));
6466
}
6567

66-
DocumentPermissions(Set<BytesReference> queries, Set<BytesReference> scopedByQueries) {
67-
if (queries == null && scopedByQueries == null) {
68-
throw new IllegalArgumentException("one of the queries or scoped queries must be provided");
69-
}
70-
this.queries = (queries != null) ? new TreeSet<>(queries) : null;
71-
this.limitedByQueries = (scopedByQueries != null) ? new TreeSet<>(scopedByQueries) : null;
68+
private DocumentPermissions(List<Set<BytesReference>> listOfQueries) {
69+
assert listOfQueries != null && false == listOfQueries.isEmpty() : "null or empty list of queries not permitted";
70+
assert listOfQueries.stream().allMatch(queries -> queries != null && false == queries.isEmpty())
71+
: "null or empty queries not permitted";
72+
// SortedSet because orders are important when they get serialised for request cache key
73+
this.listOfQueries = listOfQueries.stream()
74+
.map(queries -> queries instanceof SortedSet<BytesReference> ? queries : new TreeSet<>(queries))
75+
.toList();
7276
}
7377

74-
public Set<BytesReference> getQueries() {
75-
return queries == null ? null : Set.copyOf(queries);
78+
public List<Set<BytesReference>> getListOfQueries() {
79+
return listOfQueries;
7680
}
7781

78-
public Set<BytesReference> getLimitedByQueries() {
79-
return limitedByQueries == null ? null : Set.copyOf(limitedByQueries);
82+
public Set<BytesReference> getSingleSetOfQueries() {
83+
assert listOfQueries != null && listOfQueries.size() == 1 : "the list of queries does not have a single member";
84+
return listOfQueries.get(0);
8085
}
8186

8287
/**
8388
* @return {@code true} if either queries or scoped queries are present for document level security else returns {@code false}
8489
*/
8590
public boolean hasDocumentLevelPermissions() {
86-
return queries != null || limitedByQueries != null;
91+
return listOfQueries != null;
8792
}
8893

8994
public boolean hasStoredScript() throws IOException {
90-
if (queries != null) {
91-
for (BytesReference q : queries) {
92-
if (DLSRoleQueryValidator.hasStoredScript(q, NamedXContentRegistry.EMPTY)) {
93-
return true;
94-
}
95-
}
96-
}
97-
if (limitedByQueries != null) {
98-
for (BytesReference q : limitedByQueries) {
99-
if (DLSRoleQueryValidator.hasStoredScript(q, NamedXContentRegistry.EMPTY)) {
100-
return true;
95+
if (listOfQueries != null) {
96+
for (Set<BytesReference> queries : listOfQueries) {
97+
for (BytesReference q : queries) {
98+
if (DLSRoleQueryValidator.hasStoredScript(q, NamedXContentRegistry.EMPTY)) {
99+
return true;
100+
}
101101
}
102102
}
103103
}
@@ -125,35 +125,25 @@ public BooleanQuery filter(
125125
) throws IOException {
126126
if (hasDocumentLevelPermissions()) {
127127
evaluateQueries(SecurityQueryTemplateEvaluator.wrap(user, scriptService));
128-
BooleanQuery.Builder filter;
129-
if (evaluatedQueries != null && evaluatedLimitedByQueries != null) {
130-
filter = new BooleanQuery.Builder();
131-
BooleanQuery.Builder scopedFilter = new BooleanQuery.Builder();
132-
buildRoleQuery(shardId, searchExecutionContextProvider, evaluatedLimitedByQueries, scopedFilter);
133-
filter.add(scopedFilter.build(), FILTER);
128+
assert listOfEvaluatedQueries != null : "evaluated queries must not be null";
129+
assert false == listOfEvaluatedQueries.isEmpty() : "evaluated queries must not be empty";
134130

135-
buildRoleQuery(shardId, searchExecutionContextProvider, evaluatedQueries, filter);
136-
} else if (evaluatedQueries != null) {
137-
filter = new BooleanQuery.Builder();
138-
buildRoleQuery(shardId, searchExecutionContextProvider, evaluatedQueries, filter);
139-
} else if (evaluatedLimitedByQueries != null) {
140-
filter = new BooleanQuery.Builder();
141-
buildRoleQuery(shardId, searchExecutionContextProvider, evaluatedLimitedByQueries, filter);
142-
} else {
143-
assert false : "one of queries and limited-by queries must be non-null";
144-
return null;
131+
BooleanQuery.Builder filter = new BooleanQuery.Builder();
132+
for (int i = listOfEvaluatedQueries.size() - 1; i > 0; i--) {
133+
final BooleanQuery.Builder scopedFilter = new BooleanQuery.Builder();
134+
buildRoleQuery(shardId, searchExecutionContextProvider, listOfEvaluatedQueries.get(i), scopedFilter);
135+
filter.add(scopedFilter.build(), FILTER);
145136
}
137+
// TODO: All role queries can be filters
138+
buildRoleQuery(shardId, searchExecutionContextProvider, listOfEvaluatedQueries.get(0), filter);
146139
return filter.build();
147140
}
148141
return null;
149142
}
150143

151144
private void evaluateQueries(DlsQueryEvaluationContext context) {
152-
if (queries != null && evaluatedQueries == null) {
153-
evaluatedQueries = queries.stream().map(context::evaluate).toList();
154-
}
155-
if (limitedByQueries != null && evaluatedLimitedByQueries == null) {
156-
evaluatedLimitedByQueries = limitedByQueries.stream().map(context::evaluate).toList();
145+
if (listOfQueries != null && listOfEvaluatedQueries == null) {
146+
listOfEvaluatedQueries = listOfQueries.stream().map(queries -> queries.stream().map(context::evaluate).toList()).toList();
157147
}
158148
}
159149

@@ -216,18 +206,9 @@ static void failIfQueryUsesClient(QueryBuilder queryBuilder, QueryRewriteContext
216206
* @return {@link DocumentPermissions}
217207
*/
218208
public static DocumentPermissions filteredBy(Set<BytesReference> queries) {
219-
if (queries == null || queries.isEmpty()) {
220-
throw new IllegalArgumentException("null or empty queries not permitted");
221-
}
222209
return new DocumentPermissions(queries);
223210
}
224211

225-
/**
226-
* Create {@link DocumentPermissions} with no restriction. The {@link #getQueries()}
227-
* will return {@code null} in this case and {@link #hasDocumentLevelPermissions()}
228-
* will be {@code false}
229-
* @return {@link DocumentPermissions}
230-
*/
231212
public static DocumentPermissions allowAll() {
232213
return ALLOW_ALL;
233214
}
@@ -240,49 +221,41 @@ public static DocumentPermissions allowAll() {
240221
* @return instance of {@link DocumentPermissions}
241222
*/
242223
public DocumentPermissions limitDocumentPermissions(DocumentPermissions limitedByDocumentPermissions) {
243-
assert limitedByQueries == null && limitedByDocumentPermissions.limitedByQueries == null
244-
: "nested scoping for document permissions is not permitted";
245-
if (queries == null && limitedByDocumentPermissions.queries == null) {
224+
if (hasDocumentLevelPermissions() && limitedByDocumentPermissions.hasDocumentLevelPermissions()) {
225+
return new DocumentPermissions(
226+
Stream.concat(getListOfQueries().stream(), limitedByDocumentPermissions.getListOfQueries().stream()).toList()
227+
);
228+
} else if (hasDocumentLevelPermissions()) {
229+
return new DocumentPermissions(getListOfQueries());
230+
} else if (limitedByDocumentPermissions.hasDocumentLevelPermissions()) {
231+
return new DocumentPermissions(limitedByDocumentPermissions.getListOfQueries());
232+
} else {
246233
return DocumentPermissions.allowAll();
247234
}
248-
// TODO: should we apply the same logic here as FieldPermissions#limitFieldPermissions,
249-
// i.e. treat limited-by as queries if original queries is null?
250-
return new DocumentPermissions(queries, limitedByDocumentPermissions.queries);
251235
}
252236

253237
@Override
254238
public String toString() {
255-
return "DocumentPermissions [queries=" + queries + ", scopedByQueries=" + limitedByQueries + "]";
239+
return "DocumentPermissions [listOfQueries=" + listOfQueries + "]";
256240
}
257241

258242
@Override
259243
public void buildCacheKey(StreamOutput out, DlsQueryEvaluationContext context) throws IOException {
260-
assert false == (queries == null && limitedByQueries == null) : "one of queries and limited-by queries must be non-null";
244+
assert hasDocumentLevelPermissions() : "document permissions should not contribute to cache key when there is no DLS query";
261245
evaluateQueries(context);
262-
if (evaluatedQueries != null) {
263-
out.writeBoolean(true);
264-
out.writeCollection(evaluatedQueries, StreamOutput::writeString);
265-
} else {
266-
out.writeBoolean(false);
267-
}
268-
if (evaluatedLimitedByQueries != null) {
269-
out.writeBoolean(true);
270-
out.writeCollection(evaluatedLimitedByQueries, StreamOutput::writeString);
271-
} else {
272-
out.writeBoolean(false);
273-
}
246+
out.writeCollection(listOfEvaluatedQueries, StreamOutput::writeStringCollection);
274247
}
275248

276249
@Override
277250
public boolean equals(Object o) {
278251
if (this == o) return true;
279252
if (o == null || getClass() != o.getClass()) return false;
280253
DocumentPermissions that = (DocumentPermissions) o;
281-
return Objects.equals(queries, that.queries) && Objects.equals(limitedByQueries, that.limitedByQueries);
254+
return Objects.equals(listOfQueries, that.listOfQueries);
282255
}
283256

284257
@Override
285258
public int hashCode() {
286-
return Objects.hash(queries, limitedByQueries);
259+
return Objects.hash(listOfQueries);
287260
}
288261
}

x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/termsenum/action/TransportTermsEnumAction.java

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969

7070
import java.io.Closeable;
7171
import java.io.IOException;
72+
import java.io.UncheckedIOException;
7273
import java.util.ArrayList;
7374
import java.util.Collections;
7475
import java.util.HashMap;
@@ -429,13 +430,17 @@ private boolean canAccess(
429430
Collections.emptyMap()
430431
);
431432

432-
// Unfiltered access is allowed when both base role and limiting role allow it.
433-
return hasMatchAllEquivalent(indexAccessControl.getDocumentPermissions().getQueries(), securityContext, queryShardContext)
434-
&& hasMatchAllEquivalent(
435-
indexAccessControl.getDocumentPermissions().getLimitedByQueries(),
436-
securityContext,
437-
queryShardContext
438-
);
433+
// Current user has potentially many roles and therefore potentially many queries
434+
// defining sets of docs accessible
435+
final List<Set<BytesReference>> listOfQueries = indexAccessControl.getDocumentPermissions().getListOfQueries();
436+
437+
// When the user is an API Key, its role is a limitedRole and its effective document permissions
438+
// are intersections of the two sets of queries, one belongs to the API key itself and the other belongs
439+
// to the owner user. To allow unfiltered access to termsDict, both sets of the queries must have
440+
// the "all" permission, i.e. the query can be rewritten into a MatchAll query.
441+
// The following code loop through both sets queries and returns true only when both of them
442+
// have the "all" permission.
443+
return listOfQueries.stream().allMatch(queries -> hasMatchAllEquivalent(queries, securityContext, queryShardContext));
439444
}
440445
}
441446
return true;
@@ -445,7 +450,7 @@ private boolean hasMatchAllEquivalent(
445450
Set<BytesReference> queries,
446451
SecurityContext securityContext,
447452
SearchExecutionContext queryShardContext
448-
) throws IOException {
453+
) {
449454
if (queries == null) {
450455
return true;
451456
}
@@ -458,7 +463,12 @@ private boolean hasMatchAllEquivalent(
458463
queryShardContext.getParserConfig().registry(),
459464
securityContext.getUser()
460465
);
461-
QueryBuilder rewrittenQueryBuilder = Rewriteable.rewrite(queryBuilder, queryShardContext);
466+
QueryBuilder rewrittenQueryBuilder;
467+
try {
468+
rewrittenQueryBuilder = Rewriteable.rewrite(queryBuilder, queryShardContext);
469+
} catch (IOException e) {
470+
throw new UncheckedIOException(e);
471+
}
462472
if (rewrittenQueryBuilder instanceof MatchAllQueryBuilder) {
463473
// One of the roles assigned has "all" permissions - allow unfettered access to termsDict
464474
return true;

0 commit comments

Comments
 (0)