-
Notifications
You must be signed in to change notification settings - Fork 25.3k
ES|QL: Fix queries with document level security on lookup indexes #120617
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 19 commits
8254efb
0427012
c40d800
edab3ec
d0b5512
1cdce58
c003b61
129133b
b3408fc
5d377a8
f16494a
151e4ed
a0bf736
23743b9
8040428
6d5b21e
17b40f8
ffa5fb9
85b33ff
72ed0b5
d070ed7
35cbc91
72328a1
0b30657
43a2d07
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
pr: 120617 | ||
summary: Fix queries with document level security on lookup indexes | ||
area: ES|QL | ||
type: bug | ||
issues: [120509] |
alex-spies marked this conversation as resolved.
Show resolved
Hide resolved
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -57,6 +57,9 @@ public class EsqlSecurityIT extends ESRestTestCase { | |
.user("user4", "x-pack-test-password", "user4", false) | ||
.user("user5", "x-pack-test-password", "user5", false) | ||
.user("fls_user", "x-pack-test-password", "fls_user", false) | ||
.user("fls_user2", "x-pack-test-password", "fls_user2", false) | ||
.user("fls_user3", "x-pack-test-password", "fls_user3", false) | ||
.user("dls_user", "x-pack-test-password", "dls_user", false) | ||
.user("metadata1_read2", "x-pack-test-password", "metadata1_read2", false) | ||
.user("alias_user1", "x-pack-test-password", "alias_user1", false) | ||
.user("alias_user2", "x-pack-test-password", "alias_user2", false) | ||
|
@@ -92,7 +95,7 @@ private void indexDocument(String index, int id, double value, String org) throw | |
public void indexDocuments() throws IOException { | ||
Settings lookupSettings = Settings.builder().put("index.mode", "lookup").build(); | ||
String mapping = """ | ||
"properties":{"value": {"type": "double"}, "org": {"type": "keyword"}} | ||
"properties":{"value": {"type": "double"}, "org": {"type": "keyword"}, "other": {"type": "keyword"}} | ||
"""; | ||
|
||
createIndex("index", Settings.EMPTY, mapping); | ||
|
@@ -163,6 +166,21 @@ public void indexDocuments() throws IOException { | |
"""); | ||
assertOK(client().performRequest(aliasRequest)); | ||
} | ||
|
||
createMultiRoleUsers(); | ||
} | ||
|
||
private void createMultiRoleUsers() throws IOException { | ||
Request request = new Request("POST", "_security/user/dls_user2"); | ||
request.setJsonEntity(""" | ||
{ | ||
"password" : "x-pack-test-password", | ||
"roles" : [ "dls_user", "dls_user2" ], | ||
"full_name" : "Test Role", | ||
"email" : "[email protected]" | ||
} | ||
"""); | ||
assertOK(client().performRequest(request)); | ||
} | ||
|
||
protected MapMatcher responseMatcher() { | ||
|
@@ -563,25 +581,109 @@ public void testLookupJoinIndexAllowed() throws Exception { | |
); | ||
assertThat(respMap.get("values"), equalTo(List.of(List.of(40.0, "sales")))); | ||
|
||
// Alias, should find the index and the row | ||
resp = runESQLCommand("alias_user1", "ROW x = 31.0 | EVAL value = x | LOOKUP JOIN `lookup-first-alias` ON value | KEEP x, org"); | ||
// Aliases are not allowed in LOOKUP JOIN | ||
var resp2 = expectThrows( | ||
ResponseException.class, | ||
() -> runESQLCommand("alias_user1", "ROW x = 31.0 | EVAL value = x | LOOKUP JOIN `lookup-first-alias` ON value | KEEP x, org") | ||
); | ||
|
||
assertThat(resp2.getMessage(), containsString("Aliases and index patterns are not allowed for LOOKUP JOIN [lookup-first-alias]")); | ||
assertThat(resp2.getResponse().getStatusLine().getStatusCode(), equalTo(HttpStatus.SC_BAD_REQUEST)); | ||
|
||
// Aliases are not allowed in LOOKUP JOIN, regardless of alias filters | ||
resp2 = expectThrows( | ||
ResponseException.class, | ||
() -> runESQLCommand("alias_user1", "ROW x = 123.0 | EVAL value = x | LOOKUP JOIN `lookup-first-alias` ON value | KEEP x, org") | ||
); | ||
assertThat(resp2.getMessage(), containsString("Aliases and index patterns are not allowed for LOOKUP JOIN [lookup-first-alias]")); | ||
assertThat(resp2.getResponse().getStatusLine().getStatusCode(), equalTo(HttpStatus.SC_BAD_REQUEST)); | ||
} | ||
|
||
@SuppressWarnings("unchecked") | ||
public void testLookupJoinDocLevelSecurity() throws Exception { | ||
assumeTrue( | ||
"Requires LOOKUP JOIN capability", | ||
EsqlSpecTestCase.hasCapabilities(adminClient(), List.of(EsqlCapabilities.Cap.JOIN_LOOKUP_V11.capabilityName())) | ||
); | ||
|
||
Response resp = runESQLCommand("dls_user", "ROW x = 40.0 | EVAL value = x | LOOKUP JOIN `lookup-user2` ON value | KEEP x, org"); | ||
assertOK(resp); | ||
Map<String, Object> respMap = entityAsMap(resp); | ||
assertThat( | ||
respMap.get("columns"), | ||
equalTo(List.of(Map.of("name", "x", "type", "double"), Map.of("name", "org", "type", "keyword"))) | ||
); | ||
|
||
assertThat(respMap.get("values"), equalTo(List.of(Arrays.asList(40.0, null)))); | ||
|
||
resp = runESQLCommand("dls_user", "ROW x = 32.0 | EVAL value = x | LOOKUP JOIN `lookup-user2` ON value | KEEP x, org"); | ||
assertOK(resp); | ||
respMap = entityAsMap(resp); | ||
assertThat( | ||
respMap.get("columns"), | ||
equalTo(List.of(Map.of("name", "x", "type", "double"), Map.of("name", "org", "type", "keyword"))) | ||
); | ||
assertThat(respMap.get("values"), equalTo(List.of(List.of(31.0, "sales")))); | ||
assertThat(respMap.get("values"), equalTo(List.of(List.of(32.0, "marketing")))); | ||
|
||
// Alias, for a row that's filtered out | ||
resp = runESQLCommand("alias_user1", "ROW x = 123.0 | EVAL value = x | LOOKUP JOIN `lookup-first-alias` ON value | KEEP x, org"); | ||
// same, but with a user that has two dls roles that allow him more visibility | ||
|
||
resp = runESQLCommand("dls_user2", "ROW x = 40.0 | EVAL value = x | LOOKUP JOIN `lookup-user2` ON value | KEEP x, org"); | ||
assertOK(resp); | ||
respMap = entityAsMap(resp); | ||
assertThat( | ||
respMap.get("columns"), | ||
equalTo(List.of(Map.of("name", "x", "type", "double"), Map.of("name", "org", "type", "keyword"))) | ||
); | ||
assertThat(respMap.get("values"), equalTo(List.of(Arrays.asList(123.0, null)))); | ||
|
||
assertThat(respMap.get("values"), equalTo(List.of(Arrays.asList(40.0, "sales")))); | ||
|
||
resp = runESQLCommand("dls_user2", "ROW x = 32.0 | EVAL value = x | LOOKUP JOIN `lookup-user2` ON value | KEEP x, org"); | ||
assertOK(resp); | ||
respMap = entityAsMap(resp); | ||
assertThat( | ||
respMap.get("columns"), | ||
equalTo(List.of(Map.of("name", "x", "type", "double"), Map.of("name", "org", "type", "keyword"))) | ||
); | ||
assertThat(respMap.get("values"), equalTo(List.of(List.of(32.0, "marketing")))); | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice, these are very helpful. Thanks for adding! |
||
} | ||
|
||
@SuppressWarnings("unchecked") | ||
public void testLookupJoinFieldLevelSecurity() throws Exception { | ||
assumeTrue( | ||
"Requires LOOKUP JOIN capability", | ||
EsqlSpecTestCase.hasCapabilities(adminClient(), List.of(EsqlCapabilities.Cap.JOIN_LOOKUP_V11.capabilityName())) | ||
); | ||
|
||
Response resp = runESQLCommand("fls_user2", "ROW x = 40.0 | EVAL value = x | LOOKUP JOIN `lookup-user2` ON value"); | ||
assertOK(resp); | ||
Map<String, Object> respMap = entityAsMap(resp); | ||
assertThat( | ||
respMap.get("columns"), | ||
equalTo( | ||
List.of( | ||
Map.of("name", "x", "type", "double"), | ||
Map.of("name", "value", "type", "double"), | ||
Map.of("name", "org", "type", "keyword") | ||
) | ||
) | ||
); | ||
|
||
resp = runESQLCommand("fls_user3", "ROW x = 40.0 | EVAL value = x | LOOKUP JOIN `lookup-user2` ON value"); | ||
assertOK(resp); | ||
respMap = entityAsMap(resp); | ||
assertThat( | ||
respMap.get("columns"), | ||
equalTo( | ||
List.of( | ||
Map.of("name", "x", "type", "double"), | ||
Map.of("name", "value", "type", "double"), | ||
Map.of("name", "org", "type", "keyword"), | ||
Map.of("name", "other", "type", "keyword") | ||
) | ||
) | ||
|
||
); | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorry, one last test suggestion - we can also add this in a separate PR if you're already about to make this ready to merge. I just noticed: all of the fls test cases have permission to read the I think it'd be interesting to test if forbidding this field for a user makes it so all look ups are null. And, if we wanna be extra paranoid, consider the case where the lookup index has duplicates for a given join key, so the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks Alex, I still have a couple of things to push, I'll add this one as well. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The new tests confirm the above: if a role cannot see the join key in the lookup index, the query will fail with "Unknown column" |
||
|
||
public void testLookupJoinIndexForbidden() throws Exception { | ||
|
alex-spies marked this conversation as resolved.
Show resolved
Hide resolved
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -8,10 +8,13 @@ | |
package org.elasticsearch.xpack.esql.enrich; | ||
|
||
import org.elasticsearch.TransportVersions; | ||
import org.elasticsearch.action.ActionListener; | ||
import org.elasticsearch.cluster.node.DiscoveryNode; | ||
import org.elasticsearch.cluster.service.ClusterService; | ||
import org.elasticsearch.common.io.stream.StreamInput; | ||
import org.elasticsearch.common.io.stream.StreamOutput; | ||
import org.elasticsearch.common.util.BigArrays; | ||
import org.elasticsearch.common.util.concurrent.ThreadContext; | ||
import org.elasticsearch.compute.data.Block; | ||
import org.elasticsearch.compute.data.BlockFactory; | ||
import org.elasticsearch.compute.data.BlockStreamInput; | ||
|
@@ -23,8 +26,10 @@ | |
import org.elasticsearch.index.mapper.RangeType; | ||
import org.elasticsearch.index.query.SearchExecutionContext; | ||
import org.elasticsearch.index.shard.ShardId; | ||
import org.elasticsearch.tasks.CancellableTask; | ||
import org.elasticsearch.tasks.TaskId; | ||
import org.elasticsearch.transport.TransportService; | ||
import org.elasticsearch.xpack.core.ClientHelper; | ||
import org.elasticsearch.xpack.core.security.authz.privilege.ClusterPrivilegeResolver; | ||
import org.elasticsearch.xpack.esql.EsqlIllegalArgumentException; | ||
import org.elasticsearch.xpack.esql.action.EsqlQueryAction; | ||
|
@@ -270,4 +275,17 @@ protected void innerRelease() { | |
} | ||
} | ||
} | ||
|
||
@Override | ||
protected void sendChildRequest( | ||
CancellableTask parentTask, | ||
ActionListener<List<Page>> delegate, | ||
DiscoveryNode targetNode, | ||
TransportRequest transportRequest | ||
) { | ||
ThreadContext threadContext = transportService.getThreadPool().getThreadContext(); | ||
try (ThreadContext.StoredContext unused = threadContext.stashWithOrigin(ClientHelper.ENRICH_ORIGIN)) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we try and add a comment here with the best understanding that we can gather on what's happening here? I think it's crucial and extremely important for how permissions are handled for ENRICH. My understanding:
@dnhatn , could you maybe confirm that this is happening, or correct me if I'm wrong? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @luigidellaquila @alex-spies I thought more about the fix. Can we move AbstractLookupService.lookupAsync public final void lookupAsync(R request, CancellableTask parentTask, ActionListener<List<Page>> listener) {
ClusterState clusterState = clusterService.state();
GroupShardsIterator<ShardIterator> shardIterators = clusterService.operationRouting()
.searchShards(clusterState, new String[] { request.index }, Map.of(), "_local");
if (shardIterators.size() != 1) {
listener.onFailure(new EsqlIllegalArgumentException("target index {} has more than one shard", request.index));
return;
}
ShardIterator shardIt = shardIterators.get(0);
ShardRouting shardRouting = shardIt.nextOrNull();
ShardId shardId = shardIt.shardId();
if (shardRouting == null) {
listener.onFailure(new UnavailableShardsException(shardId, "target index is not available"));
return;
}
DiscoveryNode targetNode = clusterState.nodes().get(shardRouting.currentNodeId());
T transportRequest = transportRequest(request, shardId);
// TODO: handle retry and avoid forking for the local lookup
sendChildRequest(parentTask, listener, targetNode, transportRequest);
} EnrichLookupService#sendChildRequest ThreadContext threadContext = transportService.getThreadPool().getThreadContext();
ActionListener<List<Page>> listener = ContextPreservingActionListener.wrapPreservingContext(outListener, threadContext);
hasEnrichPrivilege(listener.delegateFailureAndWrap((delegate, ignored) -> {
try (ThreadContext.StoredContext unused = threadContext.stashWithOrigin(ClientHelper.ENRICH_ORIGIN)) {
super.sendChildRequest(parentTask, delegate, targetNode, transportRequest);
}
})); There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks @dnhatn, I agree, it will make things much more clear. Doing it now. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agree, with @dnhatn 's suggestion the whole situation is more localized and more clear. Personally, I'd still leave a comment in place because for people unfamiliar with this code, it can be unexpected that the call to |
||
super.sendChildRequest(parentTask, delegate, targetNode, transportRequest); | ||
} | ||
} | ||
} |
Uh oh!
There was an error while loading. Please reload this page.