Skip to content

Commit 102cfe7

Browse files
authored
Handle a edge case for validation of API key role descriptors (#76959) (#76963)
* Handle a edge case for validation of API key role descriptors (#76959) This PR fixes a BWC edge case: In a mixed cluster, e.g. rolling upgrade, API keys can sometimes fail to validate due to mismatch of role descriptors depending on where the request is initially authenticated. * checkstyle
1 parent 252fd75 commit 102cfe7

File tree

7 files changed

+253
-19
lines changed

7 files changed

+253
-19
lines changed

x-pack/plugin/security/qa/service-account/src/javaRestTest/java/org/elasticsearch/xpack/security/authc/service/ServiceAccountIT.java

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@
2626
import java.io.IOException;
2727
import java.net.URISyntaxException;
2828
import java.net.URL;
29+
import java.nio.charset.StandardCharsets;
2930
import java.nio.file.Path;
31+
import java.util.Base64;
3032
import java.util.List;
3133
import java.util.Locale;
3234
import java.util.Map;
@@ -381,10 +383,30 @@ public void testManageOwnApiKey() throws IOException {
381383
createApiKeyRequest1.setOptions(requestOptions);
382384
final Response createApiKeyResponse1 = client().performRequest(createApiKeyRequest1);
383385
assertOK(createApiKeyResponse1);
384-
final String apiKeyId1 = (String) responseAsMap(createApiKeyResponse1).get("id");
386+
final Map<String, Object> createApiKeyResponseMap1 = responseAsMap(createApiKeyResponse1);
387+
final String apiKeyId1 = (String) createApiKeyResponseMap1.get("id");
385388

386389
assertApiKeys(apiKeyId1, "key-1", false, requestOptions);
387390

391+
final String base64ApiKeyKeyValue = Base64.getEncoder().encodeToString(
392+
(apiKeyId1 + ":" + createApiKeyResponseMap1.get("api_key")).getBytes(StandardCharsets.UTF_8));
393+
394+
// API key can monitor cluster
395+
final Request mainRequest = new Request("GET", "/");
396+
mainRequest.setOptions(mainRequest.getOptions().toBuilder().addHeader(
397+
"Authorization", "ApiKey " + base64ApiKeyKeyValue
398+
));
399+
assertOK(client().performRequest(mainRequest));
400+
401+
// API key cannot get user
402+
final Request getUserRequest = new Request("GET", "_security/user");
403+
getUserRequest.setOptions(getUserRequest.getOptions().toBuilder().addHeader(
404+
"Authorization", "ApiKey " + base64ApiKeyKeyValue
405+
));
406+
final ResponseException e = expectThrows(ResponseException.class, () -> client().performRequest(getUserRequest));
407+
assertThat(e.getResponse().getStatusLine().getStatusCode(), equalTo(403));
408+
assertThat(e.getMessage(), containsString("is unauthorized for API key"));
409+
388410
final Request invalidateApiKeysRequest = new Request("DELETE", "_security/api_key");
389411
invalidateApiKeysRequest.setJsonEntity("{\"ids\":[\"" + apiKeyId1 + "\"],\"owner\":true}");
390412
invalidateApiKeysRequest.setOptions(requestOptions);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
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.security.authc.apikey;
9+
10+
import org.elasticsearch.ElasticsearchSecurityException;
11+
import org.elasticsearch.action.admin.indices.create.CreateIndexAction;
12+
import org.elasticsearch.action.admin.indices.create.CreateIndexRequest;
13+
import org.elasticsearch.action.get.GetAction;
14+
import org.elasticsearch.action.get.GetRequest;
15+
import org.elasticsearch.action.get.GetResponse;
16+
import org.elasticsearch.action.main.MainAction;
17+
import org.elasticsearch.action.main.MainRequest;
18+
import org.elasticsearch.common.Strings;
19+
import org.elasticsearch.common.settings.SecureString;
20+
import org.elasticsearch.common.settings.Settings;
21+
import org.elasticsearch.common.xcontent.XContentType;
22+
import org.elasticsearch.test.SecuritySingleNodeTestCase;
23+
import org.elasticsearch.test.XContentTestUtils;
24+
import org.elasticsearch.xpack.core.XPackSettings;
25+
import org.elasticsearch.xpack.core.security.action.CreateApiKeyAction;
26+
import org.elasticsearch.xpack.core.security.action.CreateApiKeyRequest;
27+
import org.elasticsearch.xpack.core.security.action.CreateApiKeyResponse;
28+
import org.elasticsearch.xpack.core.security.action.GrantApiKeyAction;
29+
import org.elasticsearch.xpack.core.security.action.GrantApiKeyRequest;
30+
import org.elasticsearch.xpack.core.security.action.service.CreateServiceAccountTokenAction;
31+
import org.elasticsearch.xpack.core.security.action.service.CreateServiceAccountTokenRequest;
32+
import org.elasticsearch.xpack.core.security.action.service.CreateServiceAccountTokenResponse;
33+
import org.elasticsearch.xpack.core.security.action.user.PutUserAction;
34+
import org.elasticsearch.xpack.core.security.action.user.PutUserRequest;
35+
import org.elasticsearch.xpack.core.security.authc.support.Hasher;
36+
import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
37+
import org.elasticsearch.xpack.security.authc.service.ServiceAccountService;
38+
39+
import java.io.IOException;
40+
import java.nio.charset.StandardCharsets;
41+
import java.util.Base64;
42+
import java.util.Map;
43+
44+
import static org.elasticsearch.xpack.core.security.index.RestrictedIndicesNames.SECURITY_MAIN_ALIAS;
45+
import static org.hamcrest.Matchers.containsString;
46+
import static org.hamcrest.Matchers.equalTo;
47+
import static org.hamcrest.Matchers.hasKey;
48+
49+
public class ApiKeySingleNodeTests extends SecuritySingleNodeTestCase {
50+
51+
@Override
52+
protected Settings nodeSettings() {
53+
Settings.Builder builder = Settings.builder().put(super.nodeSettings());
54+
builder.put(XPackSettings.API_KEY_SERVICE_ENABLED_SETTING.getKey(), true);
55+
return builder.build();
56+
}
57+
58+
public void testCreatingApiKeyWithNoAccess() {
59+
final PutUserRequest putUserRequest = new PutUserRequest();
60+
final String username = randomAlphaOfLength(8);
61+
putUserRequest.username(username);
62+
final SecureString password = new SecureString("super-strong-password".toCharArray());
63+
putUserRequest.passwordHash(Hasher.PBKDF2.hash(password));
64+
putUserRequest.roles(Strings.EMPTY_ARRAY);
65+
client().execute(PutUserAction.INSTANCE, putUserRequest).actionGet();
66+
67+
final GrantApiKeyRequest grantApiKeyRequest = new GrantApiKeyRequest();
68+
grantApiKeyRequest.getGrant().setType("password");
69+
grantApiKeyRequest.getGrant().setUsername(username);
70+
grantApiKeyRequest.getGrant().setPassword(password);
71+
grantApiKeyRequest.getApiKeyRequest().setName(randomAlphaOfLength(8));
72+
grantApiKeyRequest.getApiKeyRequest().setRoleDescriptors(org.elasticsearch.core.List.of(
73+
new RoleDescriptor("x", new String[] { "all" },
74+
new RoleDescriptor.IndicesPrivileges[]{
75+
RoleDescriptor.IndicesPrivileges.builder().indices("*").privileges("all").allowRestrictedIndices(true).build()
76+
},
77+
null, null, null, null, null)));
78+
final CreateApiKeyResponse createApiKeyResponse = client().execute(GrantApiKeyAction.INSTANCE, grantApiKeyRequest).actionGet();
79+
80+
final String base64ApiKeyKeyValue = Base64.getEncoder().encodeToString(
81+
(createApiKeyResponse.getId() + ":" + createApiKeyResponse.getKey().toString()).getBytes(StandardCharsets.UTF_8));
82+
83+
// No cluster access
84+
final ElasticsearchSecurityException e1 = expectThrows(
85+
ElasticsearchSecurityException.class,
86+
() -> client().filterWithHeader(org.elasticsearch.core.Map.of("Authorization", "ApiKey " + base64ApiKeyKeyValue))
87+
.execute(MainAction.INSTANCE, new MainRequest())
88+
.actionGet());
89+
assertThat(e1.status().getStatus(), equalTo(403));
90+
assertThat(e1.getMessage(), containsString("is unauthorized for API key"));
91+
92+
// No index access
93+
final ElasticsearchSecurityException e2 = expectThrows(
94+
ElasticsearchSecurityException.class,
95+
() -> client().filterWithHeader(org.elasticsearch.core.Map.of("Authorization", "ApiKey " + base64ApiKeyKeyValue))
96+
.execute(CreateIndexAction.INSTANCE, new CreateIndexRequest(
97+
randomFrom(randomAlphaOfLengthBetween(3, 8), SECURITY_MAIN_ALIAS)))
98+
.actionGet());
99+
assertThat(e2.status().getStatus(), equalTo(403));
100+
assertThat(e2.getMessage(), containsString("is unauthorized for API key"));
101+
}
102+
103+
public void testServiceAccountApiKey() throws IOException {
104+
final CreateServiceAccountTokenRequest createServiceAccountTokenRequest =
105+
new CreateServiceAccountTokenRequest("elastic", "fleet-server", randomAlphaOfLength(8));
106+
final CreateServiceAccountTokenResponse createServiceAccountTokenResponse =
107+
client().execute(CreateServiceAccountTokenAction.INSTANCE, createServiceAccountTokenRequest).actionGet();
108+
109+
final CreateApiKeyResponse createApiKeyResponse =
110+
client()
111+
.filterWithHeader(org.elasticsearch.core.Map.of("Authorization", "Bearer " + createServiceAccountTokenResponse.getValue()))
112+
.execute(CreateApiKeyAction.INSTANCE, new CreateApiKeyRequest(randomAlphaOfLength(8), null, null))
113+
.actionGet();
114+
115+
final Map<String, Object> apiKeyDocument = getApiKeyDocument(createApiKeyResponse.getId());
116+
117+
@SuppressWarnings("unchecked")
118+
final Map<String, Object> fleetServerRoleDescriptor =
119+
(Map<String, Object>) apiKeyDocument.get("limited_by_role_descriptors");
120+
assertThat(fleetServerRoleDescriptor.size(), equalTo(1));
121+
assertThat(fleetServerRoleDescriptor, hasKey("elastic/fleet-server"));
122+
123+
@SuppressWarnings("unchecked")
124+
final Map<String, ?> descriptor = (Map<String, ?>) fleetServerRoleDescriptor.get("elastic/fleet-server");
125+
126+
final RoleDescriptor roleDescriptor = RoleDescriptor.parse("elastic/fleet-server",
127+
XContentTestUtils.convertToXContent(descriptor, XContentType.JSON),
128+
false,
129+
XContentType.JSON);
130+
assertThat(roleDescriptor, equalTo(ServiceAccountService.getServiceAccounts().get("elastic/fleet-server").roleDescriptor()));
131+
}
132+
133+
private Map<String, Object> getApiKeyDocument(String apiKeyId) {
134+
final GetResponse getResponse =
135+
client().execute(GetAction.INSTANCE, new GetRequest(".security-7", apiKeyId)).actionGet();
136+
return getResponse.getSource();
137+
}
138+
}

x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@
8888
import org.elasticsearch.xpack.core.security.authc.Authentication;
8989
import org.elasticsearch.xpack.core.security.authc.Authentication.RealmRef;
9090
import org.elasticsearch.xpack.core.security.authc.AuthenticationResult;
91+
import org.elasticsearch.xpack.core.security.authc.service.ServiceAccountSettings;
9192
import org.elasticsearch.xpack.core.security.authc.support.Hasher;
9293
import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
9394
import org.elasticsearch.xpack.core.security.user.User;
@@ -182,6 +183,18 @@ public class ApiKeyService {
182183
public static final Setting<TimeValue> DOC_CACHE_TTL_SETTING = Setting.timeSetting("xpack.security.authc.api_key.doc_cache.ttl",
183184
TimeValue.timeValueMinutes(5), TimeValue.timeValueMinutes(0), TimeValue.timeValueMinutes(15), Property.NodeScope);
184185

186+
// This following fixed role descriptor is for fleet-server BWC on and before 7.14.
187+
// It is fixed and must NOT be updated when the fleet-server service account updates.
188+
private static final BytesArray FLEET_SERVER_ROLE_DESCRIPTOR_BYTES_V_7_14 = new BytesArray(
189+
"{\"elastic/fleet-server\":{\"cluster\":[\"monitor\",\"manage_own_api_key\"]," +
190+
"\"indices\":[{\"names\":[\"logs-*\",\"metrics-*\",\"traces-*\",\"synthetics-*\"," +
191+
"\".logs-endpoint.diagnostic.collection-*\"]," +
192+
"\"privileges\":[\"write\",\"create_index\",\"auto_configure\"],\"allow_restricted_indices\":false}," +
193+
"{\"names\":[\".fleet-*\"],\"privileges\":[\"read\",\"write\",\"monitor\",\"create_index\",\"auto_configure\"]," +
194+
"\"allow_restricted_indices\":false}],\"applications\":[],\"run_as\":[],\"metadata\":{}," +
195+
"\"transient_metadata\":{\"enabled\":true}}}"
196+
);
197+
185198
private final Clock clock;
186199
private final Client client;
187200
private final XPackLicenseState licenseState;
@@ -536,9 +549,15 @@ public Tuple<String, BytesReference> getApiKeyIdAndRoleBytes(Authentication auth
536549
.onOrAfter(VERSION_API_KEY_ROLES_AS_BYTES) : "This method only applies to authentication objects created on or after v7.9.0";
537550

538551
final Map<String, Object> metadata = authentication.getMetadata();
539-
return new Tuple<>(
540-
(String) metadata.get(API_KEY_ID_KEY),
541-
(BytesReference) metadata.get(limitedBy ? API_KEY_LIMITED_ROLE_DESCRIPTORS_KEY : API_KEY_ROLE_DESCRIPTORS_KEY));
552+
final BytesReference bytesReference =
553+
(BytesReference) metadata.get(limitedBy ? API_KEY_LIMITED_ROLE_DESCRIPTORS_KEY : API_KEY_ROLE_DESCRIPTORS_KEY);
554+
if (limitedBy && bytesReference.length() == 2 && "{}".equals(bytesReference.utf8ToString())) {
555+
if (ServiceAccountSettings.REALM_NAME.equals(metadata.get(API_KEY_CREATOR_REALM_NAME))
556+
&& "elastic/fleet-server".equals(authentication.getUser().principal())) {
557+
return new Tuple<>((String) metadata.get(API_KEY_ID_KEY), FLEET_SERVER_ROLE_DESCRIPTOR_BYTES_V_7_14);
558+
}
559+
}
560+
return new Tuple<>((String) metadata.get(API_KEY_ID_KEY), bytesReference);
542561
}
543562

544563
public static class ApiKeyRoleDescriptors {

x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/service/ElasticServiceAccounts.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ final class ElasticServiceAccounts {
4545
));
4646

4747
static final Map<String, ServiceAccount> ACCOUNTS = List.of(FLEET_ACCOUNT).stream()
48-
.collect(Collectors.toMap(a -> a.id().asPrincipal(), Function.identity()));;
48+
.collect(Collectors.toMap(a -> a.id().asPrincipal(), Function.identity()));
4949

5050
private ElasticServiceAccounts() {}
5151

x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/support/ApiKeyGenerator.java

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,17 @@
1414
import org.elasticsearch.xpack.core.security.action.CreateApiKeyRequest;
1515
import org.elasticsearch.xpack.core.security.action.CreateApiKeyResponse;
1616
import org.elasticsearch.xpack.core.security.authc.Authentication;
17+
import org.elasticsearch.xpack.core.security.authc.service.ServiceAccountSettings;
1718
import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
1819
import org.elasticsearch.xpack.core.security.authz.support.DLSRoleQueryValidator;
1920
import org.elasticsearch.xpack.security.authc.ApiKeyService;
21+
import org.elasticsearch.xpack.security.authc.service.ServiceAccount;
22+
import org.elasticsearch.xpack.security.authc.service.ServiceAccountService;
2023
import org.elasticsearch.xpack.security.authz.store.CompositeRolesStore;
2124

2225
import java.util.Arrays;
2326
import java.util.HashSet;
27+
import java.util.Set;
2428

2529
/**
2630
* Utility class for generating API keys for a provided {@link Authentication}.
@@ -48,20 +52,31 @@ public void generateApiKey(Authentication authentication, CreateApiKeyRequest re
4852
"creating derived api keys requires an explicit role descriptor that is empty (has no privileges)"));
4953
return;
5054
}
51-
rolesStore.getRoleDescriptors(new HashSet<>(Arrays.asList(authentication.getUser().roles())),
52-
ActionListener.wrap(roleDescriptors -> {
53-
for (RoleDescriptor rd : roleDescriptors) {
54-
try {
55-
DLSRoleQueryValidator.validateQueryField(rd.getIndicesPrivileges(), xContentRegistry);
56-
} catch (ElasticsearchException | IllegalArgumentException e) {
57-
listener.onFailure(e);
58-
return;
59-
}
60-
}
61-
apiKeyService.createApiKey(authentication, request, roleDescriptors, listener);
62-
},
63-
listener::onFailure));
6455

56+
final ActionListener<Set<RoleDescriptor>> roleDescriptorsListener = ActionListener.wrap(roleDescriptors -> {
57+
for (RoleDescriptor rd : roleDescriptors) {
58+
try {
59+
DLSRoleQueryValidator.validateQueryField(rd.getIndicesPrivileges(), xContentRegistry);
60+
} catch (ElasticsearchException | IllegalArgumentException e) {
61+
listener.onFailure(e);
62+
return;
63+
}
64+
}
65+
apiKeyService.createApiKey(authentication, request, roleDescriptors, listener);
66+
}, listener::onFailure);
67+
68+
if (ServiceAccountSettings.REALM_NAME.equals(authentication.getSourceRealm().getName())) {
69+
final ServiceAccount serviceAccount = ServiceAccountService.getServiceAccounts().get(authentication.getUser().principal());
70+
if (serviceAccount == null) {
71+
roleDescriptorsListener.onFailure(new ElasticsearchSecurityException(
72+
"the authentication is created by a service account that does not exist: ["
73+
+ authentication.getUser().principal() + "]"));
74+
} else {
75+
roleDescriptorsListener.onResponse(org.elasticsearch.core.Set.of(serviceAccount.roleDescriptor()));
76+
}
77+
} else {
78+
rolesStore.getRoleDescriptors(new HashSet<>(Arrays.asList(authentication.getUser().roles())), roleDescriptorsListener);
79+
}
6580
}
6681

6782
private boolean grantsAnyPrivileges(CreateApiKeyRequest request) {

x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStore.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,7 @@ private void getRolesForApiKey(Authentication authentication, ActionListener<Rol
275275
} else {
276276
buildAndCacheRoleForApiKey(authentication, true, ActionListener.wrap(
277277
limitedByRole -> roleActionListener.onResponse(
278-
limitedByRole == Role.EMPTY ? role : LimitedRole.createLimitedRole(role, limitedByRole)),
278+
LimitedRole.createLimitedRole(role, limitedByRole)),
279279
roleActionListener::onFailure
280280
));
281281
}

0 commit comments

Comments
 (0)