Skip to content

Commit 8c09fc8

Browse files
authored
A new search API for API keys - core search function (#75335)
This PR adds a new API for searching API keys. The API supports searching API keys with a controlled list of field names and a subset of Query DSL. It also provides a translation layer between the field names used in the REST layer and those in the index layer. This is to prevent tight coupling between the user facing request and index mappings so that they can evolve separately. Compared to the Get API key API, this new search API automatically applies calling user's security context similar to regular searches, e.g. if the user has only manage_own_api_key privilege, only keys owned by the user are returned in the search response. Relates: #71023
1 parent e4f7132 commit 8c09fc8

File tree

22 files changed

+1436
-12
lines changed

22 files changed

+1436
-12
lines changed

server/src/main/java/org/elasticsearch/index/query/SearchExecutionContext.java

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ public class SearchExecutionContext extends QueryRewriteContext {
107107
private NestedScope nestedScope;
108108
private final ValuesSourceRegistry valuesSourceRegistry;
109109
private final Map<String, MappedFieldType> runtimeMappings;
110+
private Predicate<String> allowedFields;
110111

111112
/**
112113
* Build a {@linkplain SearchExecutionContext}.
@@ -154,7 +155,8 @@ public SearchExecutionContext(
154155
),
155156
allowExpensiveQueries,
156157
valuesSourceRegistry,
157-
parseRuntimeMappings(runtimeMappings, mapperService)
158+
parseRuntimeMappings(runtimeMappings, mapperService),
159+
null
158160
);
159161
}
160162

@@ -177,7 +179,8 @@ public SearchExecutionContext(SearchExecutionContext source) {
177179
source.fullyQualifiedIndex,
178180
source.allowExpensiveQueries,
179181
source.valuesSourceRegistry,
180-
source.runtimeMappings
182+
source.runtimeMappings,
183+
source.allowedFields
181184
);
182185
}
183186

@@ -199,7 +202,8 @@ private SearchExecutionContext(int shardId,
199202
Index fullyQualifiedIndex,
200203
BooleanSupplier allowExpensiveQueries,
201204
ValuesSourceRegistry valuesSourceRegistry,
202-
Map<String, MappedFieldType> runtimeMappings) {
205+
Map<String, MappedFieldType> runtimeMappings,
206+
Predicate<String> allowedFields) {
203207
super(xContentRegistry, namedWriteableRegistry, client, nowInMillis);
204208
this.shardId = shardId;
205209
this.shardRequestIndex = shardRequestIndex;
@@ -218,6 +222,7 @@ private SearchExecutionContext(int shardId,
218222
this.allowExpensiveQueries = allowExpensiveQueries;
219223
this.valuesSourceRegistry = valuesSourceRegistry;
220224
this.runtimeMappings = runtimeMappings;
225+
this.allowedFields = allowedFields;
221226
}
222227

223228
private void reset() {
@@ -352,6 +357,10 @@ public boolean isFieldMapped(String name) {
352357
}
353358

354359
private MappedFieldType fieldType(String name) {
360+
// If the field is not allowed, behave as if it is not mapped
361+
if (allowedFields != null && false == allowedFields.test(name)) {
362+
return null;
363+
}
355364
MappedFieldType fieldType = runtimeMappings.get(name);
356365
return fieldType == null ? mappingLookup.getFieldType(name) : fieldType;
357366
}
@@ -419,6 +428,10 @@ public void setMapUnmappedFieldAsString(boolean mapUnmappedFieldAsString) {
419428
this.mapUnmappedFieldAsString = mapUnmappedFieldAsString;
420429
}
421430

431+
public void setAllowedFields(Predicate<String> allowedFields) {
432+
this.allowedFields = allowedFields;
433+
}
434+
422435
MappedFieldType failIfFieldMappingNotFound(String name, MappedFieldType fieldMapping) {
423436
if (fieldMapping != null || allowUnmappedFields) {
424437
return fieldMapping;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
package org.elasticsearch.xpack.core.security.action.apikey;
9+
10+
import org.elasticsearch.action.ActionType;
11+
12+
public final class QueryApiKeyAction extends ActionType<QueryApiKeyResponse> {
13+
14+
public static final String NAME = "cluster:admin/xpack/security/api_key/query";
15+
public static final QueryApiKeyAction INSTANCE = new QueryApiKeyAction();
16+
17+
private QueryApiKeyAction() {
18+
super(NAME, QueryApiKeyResponse::new);
19+
}
20+
21+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
package org.elasticsearch.xpack.core.security.action.apikey;
9+
10+
import org.elasticsearch.action.ActionRequest;
11+
import org.elasticsearch.action.ActionRequestValidationException;
12+
import org.elasticsearch.common.io.stream.StreamInput;
13+
import org.elasticsearch.common.io.stream.StreamOutput;
14+
import org.elasticsearch.core.Nullable;
15+
import org.elasticsearch.index.query.QueryBuilder;
16+
17+
import java.io.IOException;
18+
19+
public final class QueryApiKeyRequest extends ActionRequest {
20+
21+
@Nullable
22+
private final QueryBuilder queryBuilder;
23+
private boolean filterForCurrentUser;
24+
25+
public QueryApiKeyRequest() {
26+
this((QueryBuilder) null);
27+
}
28+
29+
public QueryApiKeyRequest(QueryBuilder queryBuilder) {
30+
this.queryBuilder = queryBuilder;
31+
}
32+
33+
public QueryApiKeyRequest(StreamInput in) throws IOException {
34+
super(in);
35+
queryBuilder = in.readOptionalNamedWriteable(QueryBuilder.class);
36+
}
37+
38+
public QueryBuilder getQueryBuilder() {
39+
return queryBuilder;
40+
}
41+
42+
public boolean isFilterForCurrentUser() {
43+
return filterForCurrentUser;
44+
}
45+
46+
public void setFilterForCurrentUser() {
47+
filterForCurrentUser = true;
48+
}
49+
50+
@Override
51+
public ActionRequestValidationException validate() {
52+
return null;
53+
}
54+
55+
@Override
56+
public void writeTo(StreamOutput out) throws IOException {
57+
super.writeTo(out);
58+
out.writeOptionalNamedWriteable(queryBuilder);
59+
}
60+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
package org.elasticsearch.xpack.core.security.action.apikey;
9+
10+
import org.elasticsearch.action.ActionResponse;
11+
import org.elasticsearch.common.io.stream.StreamInput;
12+
import org.elasticsearch.common.io.stream.StreamOutput;
13+
import org.elasticsearch.common.io.stream.Writeable;
14+
import org.elasticsearch.common.xcontent.ToXContentObject;
15+
import org.elasticsearch.common.xcontent.XContentBuilder;
16+
import org.elasticsearch.xpack.core.security.action.ApiKey;
17+
18+
import java.io.IOException;
19+
import java.util.Arrays;
20+
import java.util.Collection;
21+
import java.util.Collections;
22+
import java.util.Objects;
23+
24+
/**
25+
* Response for search API keys.<br>
26+
* The result contains information about the API keys that were found.
27+
*/
28+
public final class QueryApiKeyResponse extends ActionResponse implements ToXContentObject, Writeable {
29+
30+
private final ApiKey[] foundApiKeysInfo;
31+
32+
public QueryApiKeyResponse(StreamInput in) throws IOException {
33+
super(in);
34+
this.foundApiKeysInfo = in.readArray(ApiKey::new, ApiKey[]::new);
35+
}
36+
37+
public QueryApiKeyResponse(Collection<ApiKey> foundApiKeysInfo) {
38+
Objects.requireNonNull(foundApiKeysInfo, "found_api_keys_info must be provided");
39+
this.foundApiKeysInfo = foundApiKeysInfo.toArray(new ApiKey[0]);
40+
}
41+
42+
public static QueryApiKeyResponse emptyResponse() {
43+
return new QueryApiKeyResponse(Collections.emptyList());
44+
}
45+
46+
public ApiKey[] getApiKeyInfos() {
47+
return foundApiKeysInfo;
48+
}
49+
50+
@Override
51+
public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
52+
builder.startObject()
53+
.array("api_keys", (Object[]) foundApiKeysInfo);
54+
return builder.endObject();
55+
}
56+
57+
@Override
58+
public void writeTo(StreamOutput out) throws IOException {
59+
out.writeArray(foundApiKeysInfo);
60+
}
61+
62+
@Override
63+
public boolean equals(Object o) {
64+
if (this == o)
65+
return true;
66+
if (o == null || getClass() != o.getClass())
67+
return false;
68+
QueryApiKeyResponse that = (QueryApiKeyResponse) o;
69+
return Arrays.equals(foundApiKeysInfo, that.foundApiKeysInfo);
70+
}
71+
72+
@Override
73+
public int hashCode() {
74+
return Arrays.hashCode(foundApiKeysInfo);
75+
}
76+
77+
@Override
78+
public String toString() {
79+
return "QueryApiKeyResponse [foundApiKeysInfo=" + foundApiKeysInfo + "]";
80+
}
81+
82+
}

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,6 @@ private Builder(RoleDescriptor rd, @Nullable FieldPermissionsCache fieldPermissi
216216

217217
public Builder cluster(Set<String> privilegeNames, Iterable<ConfigurableClusterPrivilege> configurableClusterPrivileges) {
218218
ClusterPermission.Builder builder = ClusterPermission.builder();
219-
List<ClusterPermission> clusterPermissions = new ArrayList<>();
220219
if (privilegeNames.isEmpty() == false) {
221220
for (String name : privilegeNames) {
222221
builder = ClusterPrivilegeResolver.resolve(name).buildPermission(builder);

x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/ManageOwnApiKeyClusterPrivilege.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import org.elasticsearch.xpack.core.security.action.CreateApiKeyRequest;
1313
import org.elasticsearch.xpack.core.security.action.GetApiKeyRequest;
1414
import org.elasticsearch.xpack.core.security.action.InvalidateApiKeyRequest;
15+
import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyRequest;
1516
import org.elasticsearch.xpack.core.security.authc.Authentication;
1617
import org.elasticsearch.xpack.core.security.authc.Authentication.AuthenticationType;
1718
import org.elasticsearch.xpack.core.security.authz.permission.ClusterPermission;
@@ -75,6 +76,9 @@ protected boolean extendedCheck(String action, TransportRequest request, Authent
7576
invalidateApiKeyRequest.getUserName(), invalidateApiKeyRequest.getRealmName(),
7677
invalidateApiKeyRequest.ownedByAuthenticatedUser()));
7778
}
79+
} else if (request instanceof QueryApiKeyRequest) {
80+
final QueryApiKeyRequest queryApiKeyRequest = (QueryApiKeyRequest) request;
81+
return queryApiKeyRequest.isFilterForCurrentUser();
7882
}
7983
throw new IllegalArgumentException(
8084
"manage own api key privilege only supports API key requests (not " + request.getClass().getName() + ")");
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
package org.elasticsearch.xpack.core.security.action.apikey;
9+
10+
import org.elasticsearch.common.io.stream.BytesStreamOutput;
11+
import org.elasticsearch.common.io.stream.InputStreamStreamInput;
12+
import org.elasticsearch.common.io.stream.NamedWriteableAwareStreamInput;
13+
import org.elasticsearch.common.io.stream.NamedWriteableRegistry;
14+
import org.elasticsearch.common.io.stream.StreamInput;
15+
import org.elasticsearch.common.settings.Settings;
16+
import org.elasticsearch.index.query.BoolQueryBuilder;
17+
import org.elasticsearch.index.query.QueryBuilders;
18+
import org.elasticsearch.search.SearchModule;
19+
import org.elasticsearch.test.ESTestCase;
20+
21+
import java.io.ByteArrayInputStream;
22+
import java.io.IOException;
23+
import java.util.List;
24+
25+
import static org.hamcrest.Matchers.equalTo;
26+
import static org.hamcrest.Matchers.is;
27+
import static org.hamcrest.Matchers.nullValue;
28+
29+
public class QueryApiKeyRequestTests extends ESTestCase {
30+
31+
@Override
32+
protected NamedWriteableRegistry writableRegistry() {
33+
final SearchModule searchModule = new SearchModule(Settings.EMPTY, List.of());
34+
return new NamedWriteableRegistry(searchModule.getNamedWriteables());
35+
}
36+
37+
public void testReadWrite() throws IOException {
38+
final QueryApiKeyRequest request1 = new QueryApiKeyRequest();
39+
try (BytesStreamOutput out = new BytesStreamOutput()) {
40+
request1.writeTo(out);
41+
try (StreamInput in = new InputStreamStreamInput(new ByteArrayInputStream(out.bytes().array()))) {
42+
assertThat(new QueryApiKeyRequest(in).getQueryBuilder(), nullValue());
43+
}
44+
}
45+
46+
final BoolQueryBuilder boolQueryBuilder2 = QueryBuilders.boolQuery()
47+
.filter(QueryBuilders.termQuery("foo", "bar"))
48+
.should(QueryBuilders.idsQuery().addIds("id1", "id2"))
49+
.must(QueryBuilders.wildcardQuery("a.b", "t*y"))
50+
.mustNot(QueryBuilders.prefixQuery("value", "prod"));
51+
final QueryApiKeyRequest request2 = new QueryApiKeyRequest(boolQueryBuilder2);
52+
try (BytesStreamOutput out = new BytesStreamOutput()) {
53+
request2.writeTo(out);
54+
try (StreamInput in = new NamedWriteableAwareStreamInput(out.bytes().streamInput(), writableRegistry())) {
55+
final QueryApiKeyRequest deserialized = new QueryApiKeyRequest(in);
56+
assertThat(deserialized.getQueryBuilder().getClass(), is(BoolQueryBuilder.class));
57+
assertThat((BoolQueryBuilder) deserialized.getQueryBuilder(), equalTo(boolQueryBuilder2));
58+
}
59+
}
60+
}
61+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
package org.elasticsearch.xpack.core.security.action.apikey;
9+
10+
import org.elasticsearch.common.io.stream.Writeable;
11+
import org.elasticsearch.test.AbstractWireSerializingTestCase;
12+
import org.elasticsearch.xpack.core.security.action.ApiKey;
13+
import org.elasticsearch.xpack.core.security.action.ApiKeyTests;
14+
15+
import java.io.IOException;
16+
import java.time.Instant;
17+
import java.util.ArrayList;
18+
import java.util.Arrays;
19+
import java.util.List;
20+
import java.util.Map;
21+
import java.util.stream.Collectors;
22+
23+
public class QueryApiKeyResponseTests extends AbstractWireSerializingTestCase<QueryApiKeyResponse> {
24+
25+
@Override
26+
protected Writeable.Reader<QueryApiKeyResponse> instanceReader() {
27+
return QueryApiKeyResponse::new;
28+
}
29+
30+
@Override
31+
protected QueryApiKeyResponse createTestInstance() {
32+
final List<ApiKey> apiKeys = randomList(0, 3, this::randomApiKeyInfo);
33+
return new QueryApiKeyResponse(apiKeys);
34+
}
35+
36+
@Override
37+
protected QueryApiKeyResponse mutateInstance(QueryApiKeyResponse instance) throws IOException {
38+
final ArrayList<ApiKey> apiKeyInfos =
39+
Arrays.stream(instance.getApiKeyInfos()).collect(Collectors.toCollection(ArrayList::new));
40+
switch (randomIntBetween(0, 2)) {
41+
case 0:
42+
apiKeyInfos.add(randomApiKeyInfo());
43+
return new QueryApiKeyResponse(apiKeyInfos);
44+
case 1:
45+
if (false == apiKeyInfos.isEmpty()) {
46+
return new QueryApiKeyResponse(apiKeyInfos.subList(1, apiKeyInfos.size()));
47+
} else {
48+
apiKeyInfos.add(randomApiKeyInfo());
49+
return new QueryApiKeyResponse(apiKeyInfos);
50+
}
51+
default:
52+
if (false == apiKeyInfos.isEmpty()) {
53+
final int index = randomIntBetween(0, apiKeyInfos.size() - 1);
54+
apiKeyInfos.set(index, randomApiKeyInfo());
55+
} else {
56+
apiKeyInfos.add(randomApiKeyInfo());
57+
}
58+
return new QueryApiKeyResponse(apiKeyInfos);
59+
}
60+
}
61+
62+
private ApiKey randomApiKeyInfo() {
63+
final String name = randomAlphaOfLengthBetween(3, 8);
64+
final String id = randomAlphaOfLength(22);
65+
final String username = randomAlphaOfLengthBetween(3, 8);
66+
final String realm_name = randomAlphaOfLengthBetween(3, 8);
67+
final Instant creation = Instant.ofEpochMilli(randomMillisUpToYear9999());
68+
final Instant expiration = randomBoolean() ? Instant.ofEpochMilli(randomMillisUpToYear9999()) : null;
69+
final Map<String, Object> metadata = ApiKeyTests.randomMetadata();
70+
return new ApiKey(name, id, creation, expiration, false, username, realm_name, metadata);
71+
}
72+
}

0 commit comments

Comments
 (0)