diff --git a/docs/changelog/107411.yaml b/docs/changelog/107411.yaml new file mode 100644 index 0000000000000..fda040bcdab80 --- /dev/null +++ b/docs/changelog/107411.yaml @@ -0,0 +1,5 @@ +pr: 107411 +summary: Invalidating cross cluster API keys requires `manage_security` +area: Security +type: enhancement +issues: [] diff --git a/x-pack/plugin/security/qa/security-trial/src/javaRestTest/java/org/elasticsearch/xpack/security/apikey/ApiKeyRestIT.java b/x-pack/plugin/security/qa/security-trial/src/javaRestTest/java/org/elasticsearch/xpack/security/apikey/ApiKeyRestIT.java index 9c22a6bb4d210..d8e6bc21fb4ed 100644 --- a/x-pack/plugin/security/qa/security-trial/src/javaRestTest/java/org/elasticsearch/xpack/security/apikey/ApiKeyRestIT.java +++ b/x-pack/plugin/security/qa/security-trial/src/javaRestTest/java/org/elasticsearch/xpack/security/apikey/ApiKeyRestIT.java @@ -42,6 +42,8 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; import static org.elasticsearch.test.SecuritySettingsSourceField.ES_TEST_ROOT_ROLE; import static org.elasticsearch.test.SecuritySettingsSourceField.ES_TEST_ROOT_ROLE_DESCRIPTOR; @@ -971,16 +973,21 @@ public void testCreateCrossClusterApiKey() throws IOException { }""", false))); assertThat(fetchResponse.evaluate("api_keys.0.limited_by"), nullValue()); - final Request deleteRequest = new Request("DELETE", "/_security/api_key"); - deleteRequest.setJsonEntity(Strings.format(""" - {"ids": ["%s"]}""", apiKeyId)); - if (randomBoolean()) { - setUserForRequest(deleteRequest, MANAGE_SECURITY_USER, END_USER_PASSWORD); - } else { - setUserForRequest(deleteRequest, MANAGE_API_KEY_USER, END_USER_PASSWORD); + // Cannot invalidate cross cluster API keys with manage_api_key + { + final ObjectPath deleteResponse = invalidateApiKeys(MANAGE_API_KEY_USER, apiKeyId); + final List> errors = deleteResponse.evaluate("error_details"); + assertThat( + getErrorReasons(errors), + containsInAnyOrder(containsString("Cannot invalidate cross-cluster API key [" + apiKeyId + "]")) + ); + } + + { + final ObjectPath deleteResponse = invalidateApiKeys(MANAGE_SECURITY_USER, apiKeyId); + assertThat(deleteResponse.evaluate("invalidated_api_keys"), equalTo(List.of(apiKeyId))); + assertThat(deleteResponse.evaluate("error_count"), equalTo(0)); } - final ObjectPath deleteResponse = assertOKAndCreateObjectPath(client().performRequest(deleteRequest)); - assertThat(deleteResponse.evaluate("invalidated_api_keys"), equalTo(List.of(apiKeyId))); // Cannot create cross-cluster API keys with either manage_api_key or manage_own_api_key privilege if (randomBoolean()) { @@ -993,6 +1000,157 @@ public void testCreateCrossClusterApiKey() throws IOException { assertThat(e.getMessage(), containsString("action [cluster:admin/xpack/security/cross_cluster/api_key/create] is unauthorized")); } + public void testInvalidateCrossClusterApiKeys() throws IOException { + final String id1 = createCrossClusterApiKey(MANAGE_SECURITY_USER); + final String id2 = createCrossClusterApiKey(MANAGE_SECURITY_USER); + final String id3 = createApiKey(MANAGE_API_KEY_USER, "rest-api-key-1", Map.of()).id(); + final String id4 = createApiKey(MANAGE_API_KEY_USER, "rest-api-key-2", Map.of()).id(); + + // `manage_api_key` user cannot delete cross cluster API keys + { + final ObjectPath deleteResponse = invalidateApiKeys(MANAGE_API_KEY_USER, id1, id2); + assertThat(deleteResponse.evaluate("invalidated_api_keys"), is(empty())); + final List> errors = deleteResponse.evaluate("error_details"); + assertThat( + getErrorReasons(errors), + containsInAnyOrder( + containsString("Cannot invalidate cross-cluster API key [" + id1 + "]"), + containsString("Cannot invalidate cross-cluster API key [" + id2 + "]") + ) + ); + } + + // `manage_api_key` user can delete REST API keys, in mixed request + { + final ObjectPath deleteResponse = invalidateApiKeys(MANAGE_API_KEY_USER, id1, id2, id3); + assertThat(deleteResponse.evaluate("invalidated_api_keys"), contains(id3)); + final List> errors = deleteResponse.evaluate("error_details"); + assertThat( + getErrorReasons(errors), + containsInAnyOrder( + containsString("Cannot invalidate cross-cluster API key [" + id1 + "]"), + containsString("Cannot invalidate cross-cluster API key [" + id2 + "]") + ) + ); + } + + // `manage_security` user can delete both cross-cluster and REST API keys + { + final ObjectPath deleteResponse = invalidateApiKeys(MANAGE_SECURITY_USER, id1, id2, id4); + assertThat(deleteResponse.evaluate("invalidated_api_keys"), containsInAnyOrder(id1, id2, id4)); + assertThat(deleteResponse.evaluate("error_count"), equalTo(0)); + } + + // owner that loses `manage_security` cannot invalidate cross cluster API key anymore + { + final String user = "temp_manage_security_user"; + createUser(user, END_USER_PASSWORD, List.of("temp_manage_security_role")); + createRole("temp_manage_security_role", Set.of("manage_security")); + final String apiKeyId = createCrossClusterApiKey(user); + + // createRole can also be used to update + createRole("temp_manage_security_role", Set.of("manage_api_key")); + + { + final ObjectPath deleteResponse = invalidateApiKeys(user, apiKeyId); + assertThat(deleteResponse.evaluate("invalidated_api_keys"), is(empty())); + final List> errors = deleteResponse.evaluate("error_details"); + assertThat( + getErrorReasons(errors), + containsInAnyOrder(containsString("Cannot invalidate cross-cluster API key [" + apiKeyId + "]")) + ); + } + + // also test other invalidation options, e.g., username and realm_name + { + final ObjectPath deleteResponse = invalidateApiKeysWithPayload(user, """ + {"username": "temp_manage_security_user"}"""); + assertThat(deleteResponse.evaluate("invalidated_api_keys"), is(empty())); + final List> errors = deleteResponse.evaluate("error_details"); + assertThat( + getErrorReasons(errors), + containsInAnyOrder(containsString("Cannot invalidate cross-cluster API key [" + apiKeyId + "]")) + ); + } + + { + final ObjectPath deleteResponse = invalidateApiKeysWithPayload(user, """ + {"realm_name": "default_native"}"""); + assertThat(deleteResponse.evaluate("invalidated_api_keys"), is(empty())); + final List> errors = deleteResponse.evaluate("error_details"); + assertThat( + getErrorReasons(errors), + containsInAnyOrder(containsString("Cannot invalidate cross-cluster API key [" + apiKeyId + "]")) + ); + } + + { + final ObjectPath deleteResponse = invalidateApiKeysWithPayload(user, """ + {"owner": "true"}"""); + assertThat(deleteResponse.evaluate("invalidated_api_keys"), is(empty())); + final List> errors = deleteResponse.evaluate("error_details"); + assertThat( + getErrorReasons(errors), + containsInAnyOrder(containsString("Cannot invalidate cross-cluster API key [" + apiKeyId + "]")) + ); + } + + { + final ObjectPath deleteResponse = invalidateApiKeysWithPayload(MANAGE_SECURITY_USER, randomFrom(""" + {"username": "temp_manage_security_user"}""", """ + {"realm_name": "default_native"}""", """ + {"realm_name": "default_native", "username": "temp_manage_security_user"}""")); + assertThat(deleteResponse.evaluate("invalidated_api_keys"), containsInAnyOrder(apiKeyId)); + assertThat(deleteResponse.evaluate("error_count"), equalTo(0)); + } + + deleteUser(user); + deleteRole("temp_manage_security_role"); + } + } + + private ObjectPath invalidateApiKeys(String user, String... ids) throws IOException { + return invalidateApiKeysWithPayload(user, Strings.format(""" + {"ids": [%s]}""", Stream.of(ids).map(s -> "\"" + s + "\"").collect(Collectors.joining(",")))); + } + + private ObjectPath invalidateApiKeysWithPayload(String user, String payload) throws IOException { + final Request deleteRequest = new Request("DELETE", "/_security/api_key"); + deleteRequest.setJsonEntity(payload); + setUserForRequest(deleteRequest, user, END_USER_PASSWORD); + return assertOKAndCreateObjectPath(client().performRequest(deleteRequest)); + } + + private static List getErrorReasons(List> errors) { + return errors.stream().map(e -> { + try { + return (String) ObjectPath.evaluate(e, "reason"); + } catch (IOException ex) { + throw new RuntimeException(ex); + } + }).collect(Collectors.toList()); + } + + private String createCrossClusterApiKey(String user) throws IOException { + final Request createRequest = new Request("POST", "/_security/cross_cluster/api_key"); + createRequest.setJsonEntity(""" + { + "name": "my-key", + "access": { + "search": [ + { + "names": [ "metrics" ], + "query": "{\\"term\\":{\\"score\\":42}}" + } + ] + } + }"""); + setUserForRequest(createRequest, user, END_USER_PASSWORD); + + final ObjectPath createResponse = assertOKAndCreateObjectPath(client().performRequest(createRequest)); + return createResponse.evaluate("id"); + } + public void testCannotCreateDerivedCrossClusterApiKey() throws IOException { final Request createRestApiKeyRequest = new Request("POST", "_security/api_key"); setUserForRequest(createRestApiKeyRequest, MANAGE_SECURITY_USER, END_USER_PASSWORD); @@ -1751,13 +1909,17 @@ private Response performRequestUsingRandomAuthMethod(final Request request) thro } private EncodedApiKey createApiKey(final String apiKeyName, final Map metadata) throws IOException { + return createApiKey(MANAGE_OWN_API_KEY_USER, apiKeyName, metadata); + } + + private EncodedApiKey createApiKey(final String username, final String apiKeyName, final Map metadata) + throws IOException { final Map createApiKeyRequestBody = Map.of("name", apiKeyName, "metadata", metadata); final Request createApiKeyRequest = new Request("POST", "_security/api_key"); createApiKeyRequest.setJsonEntity(XContentTestUtils.convertToXContent(createApiKeyRequestBody, XContentType.JSON).utf8ToString()); createApiKeyRequest.setOptions( - RequestOptions.DEFAULT.toBuilder() - .addHeader("Authorization", headerFromRandomAuthMethod(MANAGE_OWN_API_KEY_USER, END_USER_PASSWORD)) + RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", headerFromRandomAuthMethod(username, END_USER_PASSWORD)) ); final Response createApiKeyResponse = client().performRequest(createApiKeyRequest); diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/apikey/TransportInvalidateApiKeyAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/apikey/TransportInvalidateApiKeyAction.java index cd3360c8ab5c2..8b938fed34d56 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/apikey/TransportInvalidateApiKeyAction.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/apikey/TransportInvalidateApiKeyAction.java @@ -10,9 +10,9 @@ import org.elasticsearch.action.ActionListener; import org.elasticsearch.action.support.ActionFilters; import org.elasticsearch.action.support.HandledTransportAction; +import org.elasticsearch.client.internal.Client; import org.elasticsearch.common.Strings; import org.elasticsearch.common.inject.Inject; -import org.elasticsearch.common.io.stream.Writeable; import org.elasticsearch.common.util.concurrent.EsExecutors; import org.elasticsearch.tasks.Task; import org.elasticsearch.transport.TransportService; @@ -20,52 +20,88 @@ import org.elasticsearch.xpack.core.security.action.apikey.InvalidateApiKeyAction; import org.elasticsearch.xpack.core.security.action.apikey.InvalidateApiKeyRequest; import org.elasticsearch.xpack.core.security.action.apikey.InvalidateApiKeyResponse; +import org.elasticsearch.xpack.core.security.action.user.HasPrivilegesAction; +import org.elasticsearch.xpack.core.security.action.user.HasPrivilegesRequest; +import org.elasticsearch.xpack.core.security.action.user.HasPrivilegesResponse; import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; +import org.elasticsearch.xpack.core.security.authz.privilege.ClusterPrivilegeResolver; import org.elasticsearch.xpack.security.authc.ApiKeyService; public final class TransportInvalidateApiKeyAction extends HandledTransportAction { private final ApiKeyService apiKeyService; private final SecurityContext securityContext; + private final Client client; @Inject public TransportInvalidateApiKeyAction( TransportService transportService, ActionFilters actionFilters, ApiKeyService apiKeyService, - SecurityContext context + SecurityContext context, + Client client ) { super( InvalidateApiKeyAction.NAME, transportService, actionFilters, - (Writeable.Reader) InvalidateApiKeyRequest::new, + InvalidateApiKeyRequest::new, EsExecutors.DIRECT_EXECUTOR_SERVICE ); this.apiKeyService = apiKeyService; this.securityContext = context; + this.client = client; } @Override protected void doExecute(Task task, InvalidateApiKeyRequest request, ActionListener listener) { - String[] apiKeyIds = request.getIds(); - String apiKeyName = request.getName(); - String username = request.getUserName(); - String[] realms = Strings.hasText(request.getRealmName()) ? new String[] { request.getRealmName() } : null; - final Authentication authentication = securityContext.getAuthentication(); if (authentication == null) { listener.onFailure(new IllegalStateException("authentication is required")); + return; } + final String[] apiKeyIds = request.getIds(); + final String apiKeyName = request.getName(); + final String username = getUsername(authentication, request); + final String[] realms = getRealms(authentication, request); + checkHasManageSecurityPrivilege( + ActionListener.wrap( + hasPrivilegesResponse -> apiKeyService.invalidateApiKeys( + realms, + username, + apiKeyName, + apiKeyIds, + hasPrivilegesResponse.isCompleteMatch(), + listener + ), + listener::onFailure + ) + ); + } + + private String getUsername(Authentication authentication, InvalidateApiKeyRequest request) { if (request.ownedByAuthenticatedUser()) { - assert username == null; - assert realms == null; - // restrict username and realm to current authenticated user. - username = authentication.getEffectiveSubject().getUser().principal(); - realms = ApiKeyService.getOwnersRealmNames(authentication); + assert request.getUserName() == null; + return authentication.getEffectiveSubject().getUser().principal(); } + return request.getUserName(); + } - apiKeyService.invalidateApiKeys(realms, username, apiKeyName, apiKeyIds, listener); + private String[] getRealms(Authentication authentication, InvalidateApiKeyRequest request) { + if (request.ownedByAuthenticatedUser()) { + assert request.getRealmName() == null; + return ApiKeyService.getOwnersRealmNames(authentication); + } + return Strings.hasText(request.getRealmName()) ? new String[] { request.getRealmName() } : null; } + private void checkHasManageSecurityPrivilege(ActionListener listener) { + final var hasPrivilegesRequest = new HasPrivilegesRequest(); + hasPrivilegesRequest.username(securityContext.getUser().principal()); + hasPrivilegesRequest.clusterPrivileges(ClusterPrivilegeResolver.MANAGE_SECURITY.name()); + hasPrivilegesRequest.indexPrivileges(new RoleDescriptor.IndicesPrivileges[0]); + hasPrivilegesRequest.applicationPrivileges(new RoleDescriptor.ApplicationResourcePrivileges[0]); + client.execute(HasPrivilegesAction.INSTANCE, hasPrivilegesRequest, ActionListener.wrap(listener::onResponse, listener::onFailure)); + } } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java index ec0e54e96f1af..ffacd72b05abf 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java @@ -32,7 +32,7 @@ import org.elasticsearch.action.search.TransportSearchAction; import org.elasticsearch.action.support.ContextPreservingActionListener; import org.elasticsearch.action.support.WriteRequest.RefreshPolicy; -import org.elasticsearch.action.update.UpdateRequest; +import org.elasticsearch.action.update.UpdateRequestBuilder; import org.elasticsearch.action.update.UpdateResponse; import org.elasticsearch.client.internal.Client; import org.elasticsearch.cluster.node.DiscoveryNode; @@ -99,6 +99,7 @@ import org.elasticsearch.xpack.core.security.authc.RealmDomain; import org.elasticsearch.xpack.core.security.authc.support.Hasher; import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; +import org.elasticsearch.xpack.core.security.authz.privilege.ClusterPrivilegeResolver; import org.elasticsearch.xpack.core.security.authz.store.ReservedRolesStore; import org.elasticsearch.xpack.core.security.authz.store.RoleReference; import org.elasticsearch.xpack.core.security.support.MetadataUtils; @@ -121,6 +122,7 @@ import java.util.Collection; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; @@ -1517,9 +1519,11 @@ private static void addErrorsForNotFoundApiKeys( /** * Invalidate API keys for given realm, user name, API key name and id. * @param realmNames realm names - * @param username user name + * @param username username * @param apiKeyName API key name * @param apiKeyIds API key ids + * @param includeCrossClusterApiKeys whether to include cross-cluster api keys in the invalidation; if false any cross-cluster api keys + * will be skipped. skipped API keys will be included in the error details of the response * @param invalidateListener listener for {@link InvalidateApiKeyResponse} */ public void invalidateApiKeys( @@ -1527,6 +1531,7 @@ public void invalidateApiKeys( String username, String apiKeyName, String[] apiKeyIds, + boolean includeCrossClusterApiKeys, ActionListener invalidateListener ) { ensureEnabled(); @@ -1546,7 +1551,6 @@ public void invalidateApiKeys( apiKeyIds, true, false, - // TODO: instead of parsing the entire API key document, we can just convert the hit to the API key ID this::convertSearchHitToApiKeyInfo, ActionListener.wrap(apiKeys -> { if (apiKeys.isEmpty()) { @@ -1559,7 +1563,7 @@ public void invalidateApiKeys( ); invalidateListener.onResponse(InvalidateApiKeyResponse.emptyResponse()); } else { - indexInvalidation(apiKeys.stream().map(ApiKey::getId).collect(Collectors.toSet()), invalidateListener); + indexInvalidation(apiKeys, includeCrossClusterApiKeys, invalidateListener); } }, invalidateListener::onFailure) ); @@ -1674,22 +1678,47 @@ private void findApiKeysForUserRealmApiKeyIdAndNameCombination( /** * Performs the actual invalidation of a collection of api keys * - * @param apiKeyIds the api keys to invalidate + * @param apiKeys the api keys to invalidate + * @param includeCrossClusterApiKeys whether to include cross-cluster api keys in the invalidation; if false any cross-cluster api keys + * will be skipped. skipped API keys will be included in the error details of the response * @param listener the listener to notify upon completion */ - private void indexInvalidation(Collection apiKeyIds, ActionListener listener) { + private void indexInvalidation( + Collection apiKeys, + boolean includeCrossClusterApiKeys, + ActionListener listener + ) { maybeStartApiKeyRemover(); - if (apiKeyIds.isEmpty()) { + if (apiKeys.isEmpty()) { listener.onFailure(new ElasticsearchSecurityException("No api key ids provided for invalidation")); } else { - BulkRequestBuilder bulkRequestBuilder = client.prepareBulk(); + final BulkRequestBuilder bulkRequestBuilder = client.prepareBulk(); final long invalidationTime = clock.instant().toEpochMilli(); - for (String apiKeyId : apiKeyIds) { - UpdateRequest request = client.prepareUpdate(SECURITY_MAIN_ALIAS, apiKeyId) - .setDoc(Map.of("api_key_invalidated", true, "invalidation_time", invalidationTime)) - .request(); - bulkRequestBuilder.add(request); + final Set apiKeyIdsToInvalidate = new HashSet<>(); + final Set crossClusterApiKeyIdsToSkip = new HashSet<>(); + final ArrayList failedRequestResponses = new ArrayList<>(); + for (ApiKey apiKey : apiKeys) { + final String apiKeyId = apiKey.getId(); + if (apiKeyIdsToInvalidate.contains(apiKeyId) || crossClusterApiKeyIdsToSkip.contains(apiKeyId)) { + continue; + } + if (false == includeCrossClusterApiKeys && ApiKey.Type.CROSS_CLUSTER.equals(apiKey.getType())) { + logger.debug("Skipping invalidation of cross cluster API key [{}]", apiKey); + failedRequestResponses.add(cannotInvalidateCrossClusterApiKeyException(apiKeyId)); + crossClusterApiKeyIdsToSkip.add(apiKeyId); + } else { + final UpdateRequestBuilder updateRequestBuilder = client.prepareUpdate(SECURITY_MAIN_ALIAS, apiKeyId) + .setDoc(Map.of("api_key_invalidated", true, "invalidation_time", invalidationTime)); + bulkRequestBuilder.add(updateRequestBuilder); + apiKeyIdsToInvalidate.add(apiKeyId); + } } + assert false == apiKeyIdsToInvalidate.isEmpty() || false == crossClusterApiKeyIdsToSkip.isEmpty(); + if (apiKeyIdsToInvalidate.isEmpty()) { + listener.onResponse(new InvalidateApiKeyResponse(Collections.emptyList(), Collections.emptyList(), failedRequestResponses)); + return; + } + assert bulkRequestBuilder.numberOfActions() > 0; bulkRequestBuilder.setRefreshPolicy(defaultCreateDocRefreshPolicy(settings)); securityIndex.prepareIndexIfNeededThenExecute( ex -> listener.onFailure(traceLog("prepare security index", ex)), @@ -1698,7 +1727,6 @@ private void indexInvalidation(Collection apiKeyIds, ActionListenerwrap(bulkResponse -> { - ArrayList failedRequestResponses = new ArrayList<>(); ArrayList previouslyInvalidated = new ArrayList<>(); ArrayList invalidated = new ArrayList<>(); for (BulkItemResponse bulkItemResponse : bulkResponse.getItems()) { @@ -1734,6 +1762,16 @@ private void indexInvalidation(Collection apiKeyIds, ActionListener listener.get()); @@ -490,7 +491,7 @@ public void testInvalidateApiKeys() throws Exception { username = randomAlphaOfLengthBetween(3, 8); } PlainActionFuture invalidateApiKeyResponseListener = new PlainActionFuture<>(); - service.invalidateApiKeys(realmNames, username, apiKeyName, apiKeyIds, invalidateApiKeyResponseListener); + service.invalidateApiKeys(realmNames, username, apiKeyName, apiKeyIds, true, invalidateApiKeyResponseListener); final BoolQueryBuilder boolQuery = QueryBuilders.boolQuery().filter(QueryBuilders.termQuery("doc_type", "api_key")); if (realmNames != null && realmNames.length > 0) { if (realmNames.length == 1) { @@ -607,7 +608,103 @@ public void testInvalidateApiKeysWillSetInvalidatedFlagAndRecordTimestamp() { when(clock.instant()).thenReturn(Instant.ofEpochMilli(invalidation)); final ApiKeyService service = createApiKeyService(); PlainActionFuture future = new PlainActionFuture<>(); - service.invalidateApiKeys(null, null, null, new String[] { apiKeyId }, future); + service.invalidateApiKeys(null, null, null, new String[] { apiKeyId }, true, future); + final InvalidateApiKeyResponse invalidateApiKeyResponse = future.actionGet(); + + assertThat(invalidateApiKeyResponse.getInvalidatedApiKeys(), equalTo(List.of(apiKeyId))); + verify(updateRequestBuilder).setDoc( + argThat( + (ArgumentMatcher>) argument -> Map.of("api_key_invalidated", true, "invalidation_time", invalidation) + .equals(argument) + ) + ); + } + + @SuppressWarnings("unchecked") + public void testInvalidateApiKeysWithSkippedCrossClusterKeysAndNullType() { + final int docId = randomIntBetween(0, Integer.MAX_VALUE); + final String apiKeyId = randomAlphaOfLength(20); + + // Mock the search request for keys to invalidate + when(client.threadPool()).thenReturn(threadPool); + when(client.prepareSearch(eq(SECURITY_MAIN_ALIAS))).thenReturn(new SearchRequestBuilder(client)); + doAnswer(invocation -> { + final var listener = (ActionListener) invocation.getArguments()[1]; + final var searchHit = SearchHit.unpooled(docId, apiKeyId); + try (XContentBuilder builder = JsonXContent.contentBuilder()) { + Map apiKeyDocMap = buildApiKeySourceDoc("some_hash".toCharArray()); + // Ensure type is null + apiKeyDocMap.remove("type"); + builder.map(apiKeyDocMap); + searchHit.sourceRef(BytesReference.bytes(builder)); + } + ActionListener.respondAndRelease( + listener, + new SearchResponse( + SearchHits.unpooled( + new SearchHit[] { searchHit }, + new TotalHits(1, TotalHits.Relation.EQUAL_TO), + randomFloat(), + null, + null, + null + ), + null, + null, + false, + null, + null, + 0, + randomAlphaOfLengthBetween(3, 8), + 1, + 1, + 0, + 10, + null, + null + ) + ); + return null; + }).when(client).search(any(SearchRequest.class), anyActionListener()); + + // Capture the Update request so that we can verify it is configured as expected + when(client.prepareBulk()).thenReturn(new BulkRequestBuilder(client)); + final var updateRequestBuilder = Mockito.spy(new UpdateRequestBuilder(client)); + when(client.prepareUpdate(eq(SECURITY_MAIN_ALIAS), eq(apiKeyId))).thenReturn(updateRequestBuilder); + + doAnswer(invocation -> { + final var listener = (ActionListener) invocation.getArguments()[1]; + listener.onResponse( + new BulkResponse( + new BulkItemResponse[] { + BulkItemResponse.success( + docId, + DocWriteRequest.OpType.UPDATE, + new UpdateResponse( + mock(ShardId.class), + apiKeyId, + randomLong(), + randomLong(), + randomLong(), + DocWriteResponse.Result.UPDATED + ) + ) }, + randomLongBetween(1, 100) + ) + ); + return null; + }).when(client).bulk(any(BulkRequest.class), anyActionListener()); + doAnswer(invocation -> { + final var listener = (ActionListener) invocation.getArguments()[2]; + listener.onResponse(mock(ClearSecurityCacheResponse.class)); + return null; + }).when(client).execute(eq(ClearSecurityCacheAction.INSTANCE), any(ClearSecurityCacheRequest.class), anyActionListener()); + + final long invalidation = randomMillisUpToYear9999(); + when(clock.instant()).thenReturn(Instant.ofEpochMilli(invalidation)); + final ApiKeyService service = createApiKeyService(); + PlainActionFuture future = new PlainActionFuture<>(); + service.invalidateApiKeys(null, null, null, new String[] { apiKeyId }, false, future); final InvalidateApiKeyResponse invalidateApiKeyResponse = future.actionGet(); assertThat(invalidateApiKeyResponse.getInvalidatedApiKeys(), equalTo(List.of(apiKeyId)));