Skip to content

Commit 8dbb25d

Browse files
authored
ESQL: LOOKUP JOIN security tests and privileges (#118447)
- Remove Enrich privilege for LOOKUP JOIN - Add security tests to lookup, to ensure they work/fail depending on user roles and privileges
1 parent 43e6fad commit 8dbb25d

File tree

5 files changed

+118
-29
lines changed

5 files changed

+118
-29
lines changed

x-pack/plugin/esql/qa/security/src/javaRestTest/java/org/elasticsearch/xpack/esql/EsqlSecurityIT.java

+90-1
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import org.junit.ClassRule;
3131

3232
import java.io.IOException;
33+
import java.util.Arrays;
3334
import java.util.List;
3435
import java.util.Locale;
3536
import java.util.Map;
@@ -87,9 +88,11 @@ private void indexDocument(String index, int id, double value, String org) throw
8788

8889
@Before
8990
public void indexDocuments() throws IOException {
91+
Settings lookupSettings = Settings.builder().put("index.mode", "lookup").build();
9092
String mapping = """
9193
"properties":{"value": {"type": "double"}, "org": {"type": "keyword"}}
9294
""";
95+
9396
createIndex("index", Settings.EMPTY, mapping);
9497
indexDocument("index", 1, 10.0, "sales");
9598
indexDocument("index", 2, 20.0, "engineering");
@@ -110,6 +113,16 @@ public void indexDocuments() throws IOException {
110113
indexDocument("indexpartial", 2, 40.0, "sales");
111114
refresh("indexpartial");
112115

116+
createIndex("lookup-user1", lookupSettings, mapping);
117+
indexDocument("lookup-user1", 1, 12.0, "engineering");
118+
indexDocument("lookup-user1", 2, 31.0, "sales");
119+
refresh("lookup-user1");
120+
121+
createIndex("lookup-user2", lookupSettings, mapping);
122+
indexDocument("lookup-user2", 1, 32.0, "marketing");
123+
indexDocument("lookup-user2", 2, 40.0, "sales");
124+
refresh("lookup-user2");
125+
113126
if (aliasExists("second-alias") == false) {
114127
Request aliasRequest = new Request("POST", "_aliases");
115128
aliasRequest.setJsonEntity("""
@@ -126,6 +139,17 @@ public void indexDocuments() throws IOException {
126139
}
127140
}
128141
},
142+
{
143+
"add": {
144+
"alias": "lookup-first-alias",
145+
"index": "lookup-user1",
146+
"filter": {
147+
"term": {
148+
"org": "sales"
149+
}
150+
}
151+
}
152+
},
129153
{
130154
"add": {
131155
"alias": "second-alias",
@@ -229,22 +253,30 @@ public void testAliasFilter() throws Exception {
229253
public void testUnauthorizedIndices() throws IOException {
230254
ResponseException error;
231255
error = expectThrows(ResponseException.class, () -> runESQLCommand("user1", "from index-user2 | stats sum(value)"));
256+
assertThat(error.getMessage(), containsString("Unknown index [index-user2]"));
232257
assertThat(error.getResponse().getStatusLine().getStatusCode(), equalTo(400));
233258

234259
error = expectThrows(ResponseException.class, () -> runESQLCommand("user2", "from index-user1 | stats sum(value)"));
260+
assertThat(error.getMessage(), containsString("Unknown index [index-user1]"));
235261
assertThat(error.getResponse().getStatusLine().getStatusCode(), equalTo(400));
236262

237263
error = expectThrows(ResponseException.class, () -> runESQLCommand("alias_user2", "from index-user2 | stats sum(value)"));
264+
assertThat(error.getMessage(), containsString("Unknown index [index-user2]"));
238265
assertThat(error.getResponse().getStatusLine().getStatusCode(), equalTo(400));
239266

240267
error = expectThrows(ResponseException.class, () -> runESQLCommand("metadata1_read2", "from index-user1 | stats sum(value)"));
268+
assertThat(error.getMessage(), containsString("Unknown index [index-user1]"));
241269
assertThat(error.getResponse().getStatusLine().getStatusCode(), equalTo(400));
242270
}
243271

244272
public void testInsufficientPrivilege() {
245-
Exception error = expectThrows(Exception.class, () -> runESQLCommand("metadata1_read2", "FROM index-user1 | STATS sum=sum(value)"));
273+
ResponseException error = expectThrows(
274+
ResponseException.class,
275+
() -> runESQLCommand("metadata1_read2", "FROM index-user1 | STATS sum=sum(value)")
276+
);
246277
logger.info("error", error);
247278
assertThat(error.getMessage(), containsString("Unknown index [index-user1]"));
279+
assertThat(error.getResponse().getStatusLine().getStatusCode(), equalTo(HttpStatus.SC_BAD_REQUEST));
248280
}
249281

250282
public void testIndexPatternErrorMessageComparison_ESQL_SearchDSL() throws Exception {
@@ -511,6 +543,63 @@ record Listen(long timestamp, String songId, double duration) {
511543
}
512544
}
513545

546+
public void testLookupJoinIndexAllowed() throws Exception {
547+
Response resp = runESQLCommand(
548+
"metadata1_read2",
549+
"ROW x = 40.0 | EVAL value = x | LOOKUP JOIN `lookup-user2` ON value | KEEP x, org"
550+
);
551+
assertOK(resp);
552+
Map<String, Object> respMap = entityAsMap(resp);
553+
assertThat(
554+
respMap.get("columns"),
555+
equalTo(List.of(Map.of("name", "x", "type", "double"), Map.of("name", "org", "type", "keyword")))
556+
);
557+
assertThat(respMap.get("values"), equalTo(List.of(List.of(40.0, "sales"))));
558+
559+
// Alias, should find the index and the row
560+
resp = runESQLCommand("alias_user1", "ROW x = 31.0 | EVAL value = x | LOOKUP JOIN `lookup-first-alias` ON value | KEEP x, org");
561+
assertOK(resp);
562+
respMap = entityAsMap(resp);
563+
assertThat(
564+
respMap.get("columns"),
565+
equalTo(List.of(Map.of("name", "x", "type", "double"), Map.of("name", "org", "type", "keyword")))
566+
);
567+
assertThat(respMap.get("values"), equalTo(List.of(List.of(31.0, "sales"))));
568+
569+
// Alias, for a row that's filtered out
570+
resp = runESQLCommand("alias_user1", "ROW x = 123.0 | EVAL value = x | LOOKUP JOIN `lookup-first-alias` ON value | KEEP x, org");
571+
assertOK(resp);
572+
respMap = entityAsMap(resp);
573+
assertThat(
574+
respMap.get("columns"),
575+
equalTo(List.of(Map.of("name", "x", "type", "double"), Map.of("name", "org", "type", "keyword")))
576+
);
577+
assertThat(respMap.get("values"), equalTo(List.of(Arrays.asList(123.0, null))));
578+
}
579+
580+
public void testLookupJoinIndexForbidden() {
581+
var resp = expectThrows(
582+
ResponseException.class,
583+
() -> runESQLCommand("metadata1_read2", "FROM lookup-user2 | EVAL value = 10.0 | LOOKUP JOIN `lookup-user1` ON value | KEEP x")
584+
);
585+
assertThat(resp.getMessage(), containsString("Unknown index [lookup-user1]"));
586+
assertThat(resp.getResponse().getStatusLine().getStatusCode(), equalTo(HttpStatus.SC_BAD_REQUEST));
587+
588+
resp = expectThrows(
589+
ResponseException.class,
590+
() -> runESQLCommand("metadata1_read2", "ROW x = 10.0 | EVAL value = x | LOOKUP JOIN `lookup-user1` ON value | KEEP x")
591+
);
592+
assertThat(resp.getMessage(), containsString("Unknown index [lookup-user1]"));
593+
assertThat(resp.getResponse().getStatusLine().getStatusCode(), equalTo(HttpStatus.SC_BAD_REQUEST));
594+
595+
resp = expectThrows(
596+
ResponseException.class,
597+
() -> runESQLCommand("alias_user1", "ROW x = 10.0 | EVAL value = x | LOOKUP JOIN `lookup-user1` ON value | KEEP x")
598+
);
599+
assertThat(resp.getMessage(), containsString("Unknown index [lookup-user1]"));
600+
assertThat(resp.getResponse().getStatusLine().getStatusCode(), equalTo(HttpStatus.SC_BAD_REQUEST));
601+
}
602+
514603
private void createEnrichPolicy() throws Exception {
515604
createIndex("songs", Settings.EMPTY, """
516605
"properties":{"song_id": {"type": "keyword"}, "title": {"type": "keyword"}, "artist": {"type": "keyword"} }

x-pack/plugin/esql/qa/security/src/javaRestTest/resources/roles.yml

+3-3
Original file line numberDiff line numberDiff line change
@@ -35,15 +35,15 @@ user2:
3535
metadata1_read2:
3636
cluster: []
3737
indices:
38-
- names: [ 'index-user1' ]
38+
- names: [ 'index-user1', 'lookup-user1' ]
3939
privileges: [ 'view_index_metadata' ]
40-
- names: [ 'index-user2' ]
40+
- names: [ 'index-user2', 'lookup-user2' ]
4141
privileges: [ 'read' ]
4242

4343
alias_user1:
4444
cluster: []
4545
indices:
46-
- names: [ 'first-alias' ]
46+
- names: [ 'first-alias', 'lookup-first-alias' ]
4747
privileges:
4848
- read
4949

x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/AbstractLookupService.java

+13-4
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,6 @@
132132
*/
133133
abstract class AbstractLookupService<R extends AbstractLookupService.Request, T extends AbstractLookupService.TransportRequest> {
134134
private final String actionName;
135-
private final String privilegeName;
136135
private final ClusterService clusterService;
137136
private final SearchService searchService;
138137
private final TransportService transportService;
@@ -143,7 +142,6 @@ abstract class AbstractLookupService<R extends AbstractLookupService.Request, T
143142

144143
AbstractLookupService(
145144
String actionName,
146-
String privilegeName,
147145
ClusterService clusterService,
148146
SearchService searchService,
149147
TransportService transportService,
@@ -152,7 +150,6 @@ abstract class AbstractLookupService<R extends AbstractLookupService.Request, T
152150
CheckedBiFunction<StreamInput, BlockFactory, T, IOException> readRequest
153151
) {
154152
this.actionName = actionName;
155-
this.privilegeName = privilegeName;
156153
this.clusterService = clusterService;
157154
this.searchService = searchService;
158155
this.transportService = transportService;
@@ -237,9 +234,21 @@ public final void lookupAsync(R request, CancellableTask parentTask, ActionListe
237234
}));
238235
}
239236

237+
/**
238+
* Get the privilege required to perform the lookup.
239+
* <p>
240+
* If null is returned, no privilege check will be performed.
241+
* </p>
242+
*/
243+
@Nullable
244+
protected abstract String getRequiredPrivilege();
245+
240246
private void hasPrivilege(ActionListener<Void> outListener) {
241247
final Settings settings = clusterService.getSettings();
242-
if (settings.hasValue(XPackSettings.SECURITY_ENABLED.getKey()) == false || XPackSettings.SECURITY_ENABLED.get(settings) == false) {
248+
String privilegeName = getRequiredPrivilege();
249+
if (privilegeName == null
250+
|| settings.hasValue(XPackSettings.SECURITY_ENABLED.getKey()) == false
251+
|| XPackSettings.SECURITY_ENABLED.get(settings) == false) {
243252
outListener.onResponse(null);
244253
return;
245254
}

x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/EnrichLookupService.java

+6-10
Original file line numberDiff line numberDiff line change
@@ -52,16 +52,7 @@ public EnrichLookupService(
5252
BigArrays bigArrays,
5353
BlockFactory blockFactory
5454
) {
55-
super(
56-
LOOKUP_ACTION_NAME,
57-
ClusterPrivilegeResolver.MONITOR_ENRICH.name(),
58-
clusterService,
59-
searchService,
60-
transportService,
61-
bigArrays,
62-
blockFactory,
63-
TransportRequest::readFrom
64-
);
55+
super(LOOKUP_ACTION_NAME, clusterService, searchService, transportService, bigArrays, blockFactory, TransportRequest::readFrom);
6556
}
6657

6758
@Override
@@ -90,6 +81,11 @@ protected QueryList queryList(TransportRequest request, SearchExecutionContext c
9081
};
9182
}
9283

84+
@Override
85+
protected String getRequiredPrivilege() {
86+
return ClusterPrivilegeResolver.MONITOR_ENRICH.name();
87+
}
88+
9389
private static void validateTypes(DataType inputDataType, MappedFieldType fieldType) {
9490
if (fieldType instanceof RangeFieldMapper.RangeFieldType rangeType) {
9591
// For range policy types, the ENRICH index field type will be one of a list of supported range types,

x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/enrich/LookupFromIndexService.java

+6-11
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323
import org.elasticsearch.search.SearchService;
2424
import org.elasticsearch.tasks.TaskId;
2525
import org.elasticsearch.transport.TransportService;
26-
import org.elasticsearch.xpack.core.security.authz.privilege.ClusterPrivilegeResolver;
2726
import org.elasticsearch.xpack.esql.EsqlIllegalArgumentException;
2827
import org.elasticsearch.xpack.esql.action.EsqlQueryAction;
2928
import org.elasticsearch.xpack.esql.core.expression.NamedExpression;
@@ -50,16 +49,7 @@ public LookupFromIndexService(
5049
BigArrays bigArrays,
5150
BlockFactory blockFactory
5251
) {
53-
super(
54-
LOOKUP_ACTION_NAME,
55-
ClusterPrivilegeResolver.MONITOR_ENRICH.name(), // TODO some other privilege
56-
clusterService,
57-
searchService,
58-
transportService,
59-
bigArrays,
60-
blockFactory,
61-
TransportRequest::readFrom
62-
);
52+
super(LOOKUP_ACTION_NAME, clusterService, searchService, transportService, bigArrays, blockFactory, TransportRequest::readFrom);
6353
}
6454

6555
@Override
@@ -83,6 +73,11 @@ protected QueryList queryList(TransportRequest request, SearchExecutionContext c
8373
return termQueryList(fieldType, context, inputBlock, inputDataType);
8474
}
8575

76+
@Override
77+
protected String getRequiredPrivilege() {
78+
return null;
79+
}
80+
8681
private static void validateTypes(DataType inputDataType, MappedFieldType fieldType) {
8782
// TODO: consider supporting implicit type conversion as done in ENRICH for some types
8883
if (fieldType.typeName().equals(inputDataType.typeName()) == false) {

0 commit comments

Comments
 (0)