diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/UpdateApiKeyRequest.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/UpdateApiKeyRequest.java new file mode 100644 index 0000000000000..8612bc4fd95d3 --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/UpdateApiKeyRequest.java @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.core.security.action.apikey; + +import org.elasticsearch.action.ActionRequest; +import org.elasticsearch.action.ActionRequestValidationException; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.core.Nullable; +import org.elasticsearch.xpack.core.security.action.role.RoleDescriptorRequestValidator; +import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; +import org.elasticsearch.xpack.core.security.support.MetadataUtils; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import static org.elasticsearch.action.ValidateActions.addValidationError; + +public final class UpdateApiKeyRequest extends ActionRequest { + + private final String id; + @Nullable + private final Map metadata; + @Nullable + private final List roleDescriptors; + + public UpdateApiKeyRequest(String id, @Nullable List roleDescriptors, @Nullable Map metadata) { + this.id = Objects.requireNonNull(id, "API key ID must not be null"); + this.roleDescriptors = roleDescriptors; + this.metadata = metadata; + } + + public UpdateApiKeyRequest(StreamInput in) throws IOException { + super(in); + this.id = in.readString(); + this.roleDescriptors = readOptionalList(in); + this.metadata = in.readMap(); + } + + @Override + public ActionRequestValidationException validate() { + ActionRequestValidationException validationException = null; + if (metadata != null && MetadataUtils.containsReservedMetadata(metadata)) { + validationException = addValidationError( + "API key metadata keys may not start with [" + MetadataUtils.RESERVED_PREFIX + "]", + validationException + ); + } + if (roleDescriptors != null) { + for (RoleDescriptor roleDescriptor : roleDescriptors) { + validationException = RoleDescriptorRequestValidator.validate(roleDescriptor, validationException); + } + } + return validationException; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + out.writeString(id); + writeOptionalList(out); + out.writeGenericMap(metadata); + } + + private List readOptionalList(StreamInput in) throws IOException { + return in.readBoolean() ? in.readList(RoleDescriptor::new) : null; + } + + private void writeOptionalList(StreamOutput out) throws IOException { + if (roleDescriptors == null) { + out.writeBoolean(false); + } else { + out.writeBoolean(true); + out.writeList(roleDescriptors); + } + } + + public String getId() { + return id; + } + + public Map getMetadata() { + return metadata; + } + + public List getRoleDescriptors() { + return roleDescriptors; + } +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/UpdateApiKeyResponse.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/UpdateApiKeyResponse.java new file mode 100644 index 0000000000000..a1ed1c6092df8 --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/UpdateApiKeyResponse.java @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.core.security.action.apikey; + +import org.elasticsearch.action.ActionResponse; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.common.io.stream.Writeable; +import org.elasticsearch.xcontent.ToXContentObject; +import org.elasticsearch.xcontent.XContentBuilder; + +import java.io.IOException; +import java.util.Objects; + +public final class UpdateApiKeyResponse extends ActionResponse implements ToXContentObject, Writeable { + private final boolean updated; + + public UpdateApiKeyResponse(boolean updated) { + this.updated = updated; + } + + public UpdateApiKeyResponse(StreamInput in) throws IOException { + super(in); + this.updated = in.readBoolean(); + } + + public boolean isUpdated() { + return updated; + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + return builder.startObject().field("updated", updated).endObject(); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeBoolean(updated); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + UpdateApiKeyResponse that = (UpdateApiKeyResponse) o; + return updated == that.updated; + } + + @Override + public int hashCode() { + return Objects.hash(updated); + } +} diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/UpdateApiKeyRequestTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/UpdateApiKeyRequestTests.java new file mode 100644 index 0000000000000..965268fb7f65a --- /dev/null +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/UpdateApiKeyRequestTests.java @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.core.security.action.apikey; + +import org.elasticsearch.common.io.stream.BytesStreamOutput; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +public class UpdateApiKeyRequestTests extends ESTestCase { + + public void testNullValuesValid() { + final var request = new UpdateApiKeyRequest("id", null, null); + assertNull(request.validate()); + } + + public void testSerialization() throws IOException { + final boolean roleDescriptorsPresent = randomBoolean(); + final List descriptorList; + if (roleDescriptorsPresent == false) { + descriptorList = null; + } else { + final int numDescriptors = randomIntBetween(0, 4); + descriptorList = new ArrayList<>(); + for (int i = 0; i < numDescriptors; i++) { + descriptorList.add(new RoleDescriptor("role_" + i, new String[] { "all" }, null, null)); + } + } + + final var id = randomAlphaOfLength(10); + final var metadata = ApiKeyTests.randomMetadata(); + final var request = new UpdateApiKeyRequest(id, descriptorList, metadata); + + try (BytesStreamOutput out = new BytesStreamOutput()) { + request.writeTo(out); + try (StreamInput in = out.bytes().streamInput()) { + final var serialized = new UpdateApiKeyRequest(in); + assertEquals(id, serialized.getId()); + assertEquals(descriptorList, serialized.getRoleDescriptors()); + assertEquals(metadata, request.getMetadata()); + } + } + } +} diff --git a/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/authc/ApiKeyIntegTests.java b/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/authc/ApiKeyIntegTests.java index 4a3e964e189c9..0a3a4c4953031 100644 --- a/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/authc/ApiKeyIntegTests.java +++ b/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/authc/ApiKeyIntegTests.java @@ -8,6 +8,7 @@ package org.elasticsearch.xpack.security.authc; import org.elasticsearch.ElasticsearchSecurityException; +import org.elasticsearch.ResourceNotFoundException; import org.elasticsearch.action.ActionRequestValidationException; import org.elasticsearch.action.DocWriteResponse; import org.elasticsearch.action.admin.cluster.node.info.NodeInfo; @@ -16,6 +17,9 @@ import org.elasticsearch.action.admin.indices.refresh.RefreshAction; import org.elasticsearch.action.admin.indices.refresh.RefreshRequestBuilder; import org.elasticsearch.action.admin.indices.refresh.RefreshResponse; +import org.elasticsearch.action.get.GetAction; +import org.elasticsearch.action.get.GetRequest; +import org.elasticsearch.action.get.GetResponse; import org.elasticsearch.action.support.PlainActionFuture; import org.elasticsearch.action.support.WriteRequest; import org.elasticsearch.action.update.UpdateResponse; @@ -33,11 +37,14 @@ import org.elasticsearch.core.TimeValue; import org.elasticsearch.core.Tuple; import org.elasticsearch.rest.RestStatus; +import org.elasticsearch.test.ESTestCase; import org.elasticsearch.test.SecurityIntegTestCase; import org.elasticsearch.test.SecuritySettingsSource; import org.elasticsearch.test.TestSecurityClient; +import org.elasticsearch.test.XContentTestUtils; import org.elasticsearch.test.rest.ObjectPath; import org.elasticsearch.threadpool.ThreadPool; +import org.elasticsearch.xcontent.XContentType; import org.elasticsearch.xpack.core.XPackSettings; import org.elasticsearch.xpack.core.security.action.ClearSecurityCacheAction; import org.elasticsearch.xpack.core.security.action.ClearSecurityCacheRequest; @@ -52,6 +59,8 @@ 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.apikey.UpdateApiKeyRequest; +import org.elasticsearch.xpack.core.security.action.apikey.UpdateApiKeyResponse; import org.elasticsearch.xpack.core.security.action.token.CreateTokenAction; import org.elasticsearch.xpack.core.security.action.token.CreateTokenRequestBuilder; import org.elasticsearch.xpack.core.security.action.token.CreateTokenResponse; @@ -59,8 +68,14 @@ import org.elasticsearch.xpack.core.security.action.user.PutUserRequest; import org.elasticsearch.xpack.core.security.action.user.PutUserResponse; import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.authc.AuthenticationTestHelper; +import org.elasticsearch.xpack.core.security.authc.RealmConfig; +import org.elasticsearch.xpack.core.security.authc.RealmDomain; +import org.elasticsearch.xpack.core.security.authc.esnative.NativeRealmSettings; +import org.elasticsearch.xpack.core.security.authc.file.FileRealmSettings; import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; import org.elasticsearch.xpack.core.security.user.User; +import org.elasticsearch.xpack.security.authz.RoleDescriptorTests; import org.elasticsearch.xpack.security.transport.filter.IPFilter; import org.junit.After; import org.junit.Before; @@ -72,6 +87,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; +import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -89,6 +105,7 @@ import java.util.stream.Stream; import static org.elasticsearch.test.SecuritySettingsSource.ES_TEST_ROOT_USER; +import static org.elasticsearch.test.SecuritySettingsSourceField.ES_TEST_ROOT_ROLE; import static org.elasticsearch.test.SecuritySettingsSourceField.TEST_PASSWORD_SECURE_STRING; import static org.elasticsearch.test.TestMatchers.throwableWithMessage; import static org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken.basicAuthHeaderValue; @@ -96,11 +113,15 @@ import static org.elasticsearch.xpack.security.Security.SECURITY_CRYPTO_THREAD_POOL_NAME; import static org.elasticsearch.xpack.security.support.SecuritySystemIndices.SECURITY_MAIN_ALIAS; import static org.hamcrest.Matchers.arrayWithSize; +import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.greaterThan; +import static org.hamcrest.Matchers.hasKey; import static org.hamcrest.Matchers.in; +import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.nullValue; @@ -1405,6 +1426,335 @@ public void testSecurityIndexStateChangeWillInvalidateApiKeyCaches() throws Exce }); } + public void testUpdateApiKey() throws ExecutionException, InterruptedException, IOException { + final Tuple> createdApiKey = createApiKey(ES_TEST_ROOT_USER, null); + final var apiKeyId = createdApiKey.v1().getId(); + + final var newRoleDescriptors = randomRoleDescriptors(); + final boolean nullRoleDescriptors = newRoleDescriptors == null; + final var expectedLimitedByRoleDescriptors = Set.of( + new RoleDescriptor(randomAlphaOfLength(10), new String[] { "all" }, null, null) + ); + final var request = new UpdateApiKeyRequest(apiKeyId, newRoleDescriptors, ApiKeyTests.randomMetadata()); + + final var serviceWithNodeName = getServiceWithNodeName(); + final PlainActionFuture listener = new PlainActionFuture<>(); + serviceWithNodeName.service() + .updateApiKey( + fileRealmAuth(serviceWithNodeName.nodeName(), ES_TEST_ROOT_USER, ES_TEST_ROOT_ROLE), + request, + expectedLimitedByRoleDescriptors, + listener + ); + final var response = listener.get(); + + assertNotNull(response); + assertTrue(response.isUpdated()); + + // Correct data returned from GET API + Client client = client().filterWithHeader( + Collections.singletonMap("Authorization", basicAuthHeaderValue(ES_TEST_ROOT_USER, TEST_PASSWORD_SECURE_STRING)) + ); + final PlainActionFuture getListener = new PlainActionFuture<>(); + client.execute(GetApiKeyAction.INSTANCE, GetApiKeyRequest.usingApiKeyId(apiKeyId, false), getListener); + GetApiKeyResponse getResponse = getListener.get(); + assertEquals(1, getResponse.getApiKeyInfos().length); + // When metadata for the update request is null (i.e., absent), we don't overwrite old metadata with it + final var expectedMetadata = request.getMetadata() != null ? request.getMetadata() : createdApiKey.v2(); + assertEquals(expectedMetadata == null ? Map.of() : expectedMetadata, getResponse.getApiKeyInfos()[0].getMetadata()); + assertEquals(ES_TEST_ROOT_USER, getResponse.getApiKeyInfos()[0].getUsername()); + assertEquals("file", getResponse.getApiKeyInfos()[0].getRealm()); + + // Test authenticate works with updated API key + final var authResponse = authenticateWithApiKey(apiKeyId, createdApiKey.v1().getKey()); + assertThat(authResponse.get(User.Fields.USERNAME.getPreferredName()), equalTo(ES_TEST_ROOT_USER)); + + // Document updated as expected + final var updatedApiKeyDoc = getApiKeyDocument(apiKeyId); + expectMetadataForApiKey(expectedMetadata, updatedApiKeyDoc); + expectRoleDescriptorForApiKey("limited_by_role_descriptors", expectedLimitedByRoleDescriptors, updatedApiKeyDoc); + if (nullRoleDescriptors) { + // Default role descriptor assigned to api key in `createApiKey` + final var expectedRoleDescriptor = new RoleDescriptor("role", new String[] { "monitor" }, null, null); + expectRoleDescriptorForApiKey("role_descriptors", List.of(expectedRoleDescriptor), updatedApiKeyDoc); + + // Create user action unauthorized because we did not update key role; it only has `monitor` cluster priv + final Map authorizationHeaders = Collections.singletonMap( + "Authorization", + "ApiKey " + getBase64EncodedApiKeyValue(createdApiKey.v1().getId(), createdApiKey.v1().getKey()) + ); + ExecutionException e = expectThrows(ExecutionException.class, () -> createUserWithRunAsRole(authorizationHeaders)); + assertThat(e.getMessage(), containsString("unauthorized")); + assertThat(e.getCause(), instanceOf(ElasticsearchSecurityException.class)); + } else { + expectRoleDescriptorForApiKey("role_descriptors", newRoleDescriptors, updatedApiKeyDoc); + // Create user action authorized because we updated key role to `all` cluster priv + final var authorizationHeaders = Collections.singletonMap( + "Authorization", + "ApiKey " + getBase64EncodedApiKeyValue(createdApiKey.v1().getId(), createdApiKey.v1().getKey()) + ); + createUserWithRunAsRole(authorizationHeaders); + } + } + + private List randomRoleDescriptors() { + int caseNo = randomIntBetween(0, 2); + return switch (caseNo) { + case 0 -> List.of(new RoleDescriptor(randomAlphaOfLength(10), new String[] { "all" }, null, null)); + case 1 -> List.of( + new RoleDescriptor(randomAlphaOfLength(10), new String[] { "all" }, null, null), + RoleDescriptorTests.randomRoleDescriptor() + ); + case 2 -> null; + default -> throw new IllegalStateException("unexpected case no"); + }; + } + + public void testUpdateApiKeyNotFoundScenarios() throws ExecutionException, InterruptedException { + final Tuple> createdApiKey = createApiKey(ES_TEST_ROOT_USER, null); + final var apiKeyId = createdApiKey.v1().getId(); + final var expectedRoleDescriptor = new RoleDescriptor(randomAlphaOfLength(10), new String[] { "all" }, null, null); + final var request = new UpdateApiKeyRequest(apiKeyId, List.of(expectedRoleDescriptor), ApiKeyTests.randomMetadata()); + + // Validate can update own API key + final var serviceWithNodeName = getServiceWithNodeName(); + final PlainActionFuture listener = new PlainActionFuture<>(); + serviceWithNodeName.service() + .updateApiKey( + fileRealmAuth(serviceWithNodeName.nodeName(), ES_TEST_ROOT_USER, ES_TEST_ROOT_ROLE), + request, + Set.of(expectedRoleDescriptor), + listener + ); + final var response = listener.get(); + + assertNotNull(response); + assertTrue(response.isUpdated()); + + // Test not found exception on non-existent API key + final var otherApiKeyId = randomValueOtherThan(apiKeyId, () -> randomAlphaOfLength(20)); + doTestUpdateApiKeyNotFound( + serviceWithNodeName, + fileRealmAuth(serviceWithNodeName.nodeName(), ES_TEST_ROOT_USER, ES_TEST_ROOT_ROLE), + new UpdateApiKeyRequest(otherApiKeyId, request.getRoleDescriptors(), request.getMetadata()) + ); + + // Test not found exception on other user's API key + final Tuple> otherUsersApiKey = createApiKey("user_with_manage_api_key_role", null); + doTestUpdateApiKeyNotFound( + serviceWithNodeName, + fileRealmAuth(serviceWithNodeName.nodeName(), ES_TEST_ROOT_USER, ES_TEST_ROOT_ROLE), + new UpdateApiKeyRequest(otherUsersApiKey.v1().getId(), request.getRoleDescriptors(), request.getMetadata()) + ); + + // Test not found exception on API key of user with the same username but from a different realm + doTestUpdateApiKeyNotFound( + serviceWithNodeName, + Authentication.newRealmAuthentication( + new User(ES_TEST_ROOT_USER, ES_TEST_ROOT_ROLE), + // Use native realm; no need to actually create user since we are injecting the authentication object directly + new Authentication.RealmRef(NativeRealmSettings.DEFAULT_NAME, NativeRealmSettings.TYPE, serviceWithNodeName.nodeName()) + ), + new UpdateApiKeyRequest(apiKeyId, request.getRoleDescriptors(), request.getMetadata()) + ); + } + + public void testInvalidUpdateApiKeyScenarios() throws ExecutionException, InterruptedException { + final Tuple> createdApiKey = createApiKey(ES_TEST_ROOT_USER, null); + final var apiKeyId = createdApiKey.v1().getId(); + + final boolean invalidated = randomBoolean(); + if (invalidated) { + final PlainActionFuture listener = new PlainActionFuture<>(); + client().execute(InvalidateApiKeyAction.INSTANCE, InvalidateApiKeyRequest.usingRealmName("file"), listener); + final var invalidateResponse = listener.get(); + assertThat(invalidateResponse.getErrors(), empty()); + assertThat(invalidateResponse.getInvalidatedApiKeys(), contains(apiKeyId)); + } + if (invalidated == false || randomBoolean()) { + final var dayBefore = Instant.now().minus(1L, ChronoUnit.DAYS); + assertTrue(Instant.now().isAfter(dayBefore)); + final var expirationDateUpdatedResponse = client().prepareUpdate(SECURITY_MAIN_ALIAS, apiKeyId) + .setDoc("expiration_time", dayBefore.toEpochMilli()) + .setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE) + .get(); + assertThat(expirationDateUpdatedResponse.getResult(), is(DocWriteResponse.Result.UPDATED)); + } + + final var roleDescriptor = new RoleDescriptor(randomAlphaOfLength(10), new String[] { "all" }, null, null); + final var request = new UpdateApiKeyRequest(apiKeyId, List.of(roleDescriptor), ApiKeyTests.randomMetadata()); + + final var serviceWithNodeName = getServiceWithNodeName(); + PlainActionFuture updateListener = new PlainActionFuture<>(); + serviceWithNodeName.service() + .updateApiKey( + fileRealmAuth(serviceWithNodeName.nodeName(), ES_TEST_ROOT_USER, ES_TEST_ROOT_ROLE), + request, + Set.of(roleDescriptor), + updateListener + ); + final var ex = expectThrows(ExecutionException.class, updateListener::get); + + assertThat(ex.getCause(), instanceOf(IllegalArgumentException.class)); + if (invalidated) { + assertThat(ex.getMessage(), containsString("cannot update invalidated API key [" + apiKeyId + "]")); + } else { + assertThat(ex.getMessage(), containsString("cannot update expired API key [" + apiKeyId + "]")); + } + + updateListener = new PlainActionFuture<>(); + serviceWithNodeName.service() + .updateApiKey(AuthenticationTestHelper.builder().apiKey().build(false), request, Set.of(roleDescriptor), updateListener); + final var apiKeysNotAllowedEx = expectThrows(ExecutionException.class, updateListener::get); + + assertThat(apiKeysNotAllowedEx.getCause(), instanceOf(IllegalArgumentException.class)); + assertThat( + apiKeysNotAllowedEx.getMessage(), + containsString("authentication via an API key is not supported for updating API keys") + ); + } + + public void testUpdateApiKeyClearsApiKeyDocCache() throws IOException, ExecutionException, InterruptedException { + final List services = Arrays.stream(internalCluster().getNodeNames()) + .map(n -> new ServiceWithNodeName(internalCluster().getInstance(ApiKeyService.class, n), n)) + .toList(); + + // Create two API keys and authenticate with them + final var apiKey1 = createApiKeyAndAuthenticateWithIt(); + final var apiKey2 = createApiKeyAndAuthenticateWithIt(); + + // Find out which nodes handled the above authentication requests + final var serviceWithNameForDoc1 = services.stream() + .filter(s -> s.service().getDocCache().get(apiKey1.v1()) != null) + .findFirst() + .orElseThrow(); + final var serviceWithNameForDoc2 = services.stream() + .filter(s -> s.service().getDocCache().get(apiKey2.v1()) != null) + .findFirst() + .orElseThrow(); + final var serviceForDoc1 = serviceWithNameForDoc1.service(); + final var serviceForDoc2 = serviceWithNameForDoc2.service(); + assertNotNull(serviceForDoc1.getFromCache(apiKey1.v1())); + assertNotNull(serviceForDoc2.getFromCache(apiKey2.v1())); + + final boolean sameServiceNode = serviceWithNameForDoc1 == serviceWithNameForDoc2; + if (sameServiceNode) { + assertEquals(2, serviceForDoc1.getDocCache().count()); + } else { + assertEquals(1, serviceForDoc1.getDocCache().count()); + assertEquals(1, serviceForDoc2.getDocCache().count()); + } + + final int serviceForDoc1AuthCacheCount = serviceForDoc1.getApiKeyAuthCache().count(); + final int serviceForDoc2AuthCacheCount = serviceForDoc2.getApiKeyAuthCache().count(); + + // Update the first key + final PlainActionFuture listener = new PlainActionFuture<>(); + serviceForDoc1.updateApiKey( + fileRealmAuth(serviceWithNameForDoc1.nodeName(), ES_TEST_ROOT_USER, ES_TEST_ROOT_ROLE), + new UpdateApiKeyRequest(apiKey1.v1(), List.of(), null), + Set.of(), + listener + ); + final var response = listener.get(); + assertNotNull(response); + assertTrue(response.isUpdated()); + + // The cache entry should be gone for the first key + if (sameServiceNode) { + assertEquals(1, serviceForDoc1.getDocCache().count()); + assertNull(serviceForDoc1.getDocCache().get(apiKey1.v1())); + assertNotNull(serviceForDoc1.getDocCache().get(apiKey2.v1())); + } else { + assertEquals(0, serviceForDoc1.getDocCache().count()); + assertEquals(1, serviceForDoc2.getDocCache().count()); + } + + // Auth cache has not been affected + assertEquals(serviceForDoc1AuthCacheCount, serviceForDoc1.getApiKeyAuthCache().count()); + assertEquals(serviceForDoc2AuthCacheCount, serviceForDoc2.getApiKeyAuthCache().count()); + } + + private void doTestUpdateApiKeyNotFound( + ServiceWithNodeName serviceWithNodeName, + Authentication authentication, + UpdateApiKeyRequest request + ) { + final PlainActionFuture listener = new PlainActionFuture<>(); + serviceWithNodeName.service().updateApiKey(authentication, request, Set.of(), listener); + final var ex = expectThrows(ExecutionException.class, listener::get); + assertThat(ex.getCause(), instanceOf(ResourceNotFoundException.class)); + assertThat(ex.getMessage(), containsString("no API key owned by requesting user found for ID [" + request.getId() + "]")); + } + + private static Authentication fileRealmAuth(String nodeName, String userName, String roleName) { + boolean includeDomain = randomBoolean(); + final var realmName = "file"; + final String realmType = FileRealmSettings.TYPE; + return randomValueOtherThanMany( + Authentication::isApiKey, + () -> AuthenticationTestHelper.builder() + .user(new User(userName, roleName)) + .realmRef( + new Authentication.RealmRef( + realmName, + realmType, + nodeName, + includeDomain + ? new RealmDomain( + ESTestCase.randomAlphaOfLengthBetween(3, 8), + Set.of(new RealmConfig.RealmIdentifier(realmType, realmName)) + ) + : null + ) + ) + .build() + ); + } + + private void expectMetadataForApiKey(Map expectedMetadata, Map actualRawApiKeyDoc) { + assertNotNull(actualRawApiKeyDoc); + @SuppressWarnings("unchecked") + final var actualMetadata = (Map) actualRawApiKeyDoc.get("metadata_flattened"); + assertThat("for api key doc " + actualRawApiKeyDoc, actualMetadata, equalTo(expectedMetadata)); + } + + @SuppressWarnings("unchecked") + private void expectRoleDescriptorForApiKey( + String roleDescriptorType, + Collection expectedRoleDescriptors, + Map actualRawApiKeyDoc + ) throws IOException { + assertNotNull(actualRawApiKeyDoc); + assertThat(roleDescriptorType, in(new String[] { "role_descriptors", "limited_by_role_descriptors" })); + final var rawRoleDescriptor = (Map) actualRawApiKeyDoc.get(roleDescriptorType); + assertEquals(expectedRoleDescriptors.size(), rawRoleDescriptor.size()); + for (RoleDescriptor expectedRoleDescriptor : expectedRoleDescriptors) { + assertThat(rawRoleDescriptor, hasKey(expectedRoleDescriptor.getName())); + final var descriptor = (Map) rawRoleDescriptor.get(expectedRoleDescriptor.getName()); + final var roleDescriptor = RoleDescriptor.parse( + expectedRoleDescriptor.getName(), + XContentTestUtils.convertToXContent(descriptor, XContentType.JSON), + false, + XContentType.JSON + ); + assertEquals(expectedRoleDescriptor, roleDescriptor); + } + } + + private Map getApiKeyDocument(String apiKeyId) { + final GetResponse getResponse = client().execute(GetAction.INSTANCE, new GetRequest(SECURITY_MAIN_ALIAS, apiKeyId)).actionGet(); + return getResponse.getSource(); + } + + private ServiceWithNodeName getServiceWithNodeName() { + final var nodeName = internalCluster().getNodeNames()[0]; + final var service = internalCluster().getInstance(ApiKeyService.class, nodeName); + return new ServiceWithNodeName(service, nodeName); + } + + private record ServiceWithNodeName(ApiKeyService service, String nodeName) {} + private Tuple createApiKeyAndAuthenticateWithIt() throws IOException { Client client = client().filterWithHeader( Collections.singletonMap("Authorization", basicAuthHeaderValue(ES_TEST_ROOT_USER, TEST_PASSWORD_SECURE_STRING)) @@ -1535,6 +1885,11 @@ private void verifyGetResponse( } } + private Tuple> createApiKey(String user, TimeValue expiration) { + final Tuple, List>> res = createApiKeys(user, 1, expiration, "monitor"); + return new Tuple<>(res.v1().get(0), res.v2().get(0)); + } + private Tuple, List>> createApiKeys(int noOfApiKeys, TimeValue expiration) { return createApiKeys(ES_TEST_ROOT_USER, noOfApiKeys, expiration, "monitor"); } @@ -1608,14 +1963,16 @@ private Tuple, List>> createApiKe * This new helper method creates the user in the native realm. */ private void createUserWithRunAsRole() throws ExecutionException, InterruptedException { + createUserWithRunAsRole(Map.of("Authorization", basicAuthHeaderValue(ES_TEST_ROOT_USER, TEST_PASSWORD_SECURE_STRING))); + } + + private void createUserWithRunAsRole(Map authHeaders) throws ExecutionException, InterruptedException { final PutUserRequest putUserRequest = new PutUserRequest(); putUserRequest.username("user_with_run_as_role"); putUserRequest.roles("run_as_role"); putUserRequest.passwordHash(SecuritySettingsSource.TEST_PASSWORD_HASHED.toCharArray()); PlainActionFuture listener = new PlainActionFuture<>(); - final Client client = client().filterWithHeader( - Map.of("Authorization", basicAuthHeaderValue(ES_TEST_ROOT_USER, TEST_PASSWORD_SECURE_STRING)) - ); + final Client client = client().filterWithHeader(authHeaders); client.execute(PutUserAction.INSTANCE, putUserRequest, listener); final PutUserResponse putUserResponse = listener.get(); assertTrue(putUserResponse.created()); 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 447ff9191ab6f..1826967ce0f20 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 @@ -12,9 +12,11 @@ import org.elasticsearch.ElasticsearchException; import org.elasticsearch.ElasticsearchSecurityException; import org.elasticsearch.ExceptionsHelper; +import org.elasticsearch.ResourceNotFoundException; import org.elasticsearch.Version; import org.elasticsearch.action.ActionListener; import org.elasticsearch.action.ActionRunnable; +import org.elasticsearch.action.DocWriteRequest; import org.elasticsearch.action.DocWriteResponse; import org.elasticsearch.action.bulk.BulkAction; import org.elasticsearch.action.bulk.BulkItemResponse; @@ -73,6 +75,7 @@ import org.elasticsearch.xcontent.XContentFactory; import org.elasticsearch.xcontent.XContentLocation; import org.elasticsearch.xcontent.XContentParser; +import org.elasticsearch.xcontent.XContentParserConfiguration; import org.elasticsearch.xcontent.XContentType; import org.elasticsearch.xpack.core.XPackSettings; import org.elasticsearch.xpack.core.security.ScrollHelper; @@ -85,6 +88,8 @@ import org.elasticsearch.xpack.core.security.action.apikey.GetApiKeyResponse; import org.elasticsearch.xpack.core.security.action.apikey.InvalidateApiKeyResponse; import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyResponse; +import org.elasticsearch.xpack.core.security.action.apikey.UpdateApiKeyRequest; +import org.elasticsearch.xpack.core.security.action.apikey.UpdateApiKeyResponse; import org.elasticsearch.xpack.core.security.authc.Authentication; import org.elasticsearch.xpack.core.security.authc.AuthenticationField; import org.elasticsearch.xpack.core.security.authc.AuthenticationResult; @@ -124,6 +129,7 @@ import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.LongAdder; import java.util.function.Consumer; +import java.util.function.Function; import java.util.function.Supplier; import java.util.stream.Collectors; @@ -248,6 +254,21 @@ public void invalidateAll() { apiKeyAuthCache.invalidateAll(); } }); + cacheInvalidatorRegistry.registerCacheInvalidator("api_key_doc", new CacheInvalidatorRegistry.CacheInvalidator() { + @Override + public void invalidate(Collection keys) { + if (apiKeyDocCache != null) { + apiKeyDocCache.invalidate(keys); + } + } + + @Override + public void invalidateAll() { + if (apiKeyDocCache != null) { + apiKeyDocCache.invalidateAll(); + } + } + }); } else { this.apiKeyAuthCache = null; this.apiKeyDocCache = null; @@ -332,6 +353,58 @@ private void createApiKeyAndIndexIt( })); } + public void updateApiKey( + final Authentication authentication, + final UpdateApiKeyRequest request, + final Set userRoles, + final ActionListener listener + ) { + ensureEnabled(); + + if (authentication == null) { + listener.onFailure(new IllegalArgumentException("authentication must be provided")); + return; + } else if (authentication.isApiKey()) { + listener.onFailure(new IllegalArgumentException("authentication via an API key is not supported for updating API keys")); + return; + } + + logger.debug("Updating API key [{}]", request.getId()); + + findVersionedApiKeyDocsForSubject(authentication, new String[] { request.getId() }, ActionListener.wrap((versionedDocs) -> { + final var apiKeyId = request.getId(); + + if (versionedDocs.isEmpty()) { + throw new ResourceNotFoundException("no API key owned by requesting user found for ID [" + apiKeyId + "]"); + } + + validateCurrentApiKeyDocForUpdate(apiKeyId, authentication, single(apiKeyId, versionedDocs).doc()); + + executeBulkRequest( + buildBulkRequestForUpdate(versionedDocs, authentication, request, userRoles), + ActionListener.wrap(bulkResponse -> translateResponseAndClearCache(apiKeyId, bulkResponse, listener), listener::onFailure) + ); + }, listener::onFailure)); + } + + // package-private for testing + void validateCurrentApiKeyDocForUpdate(String apiKeyId, Authentication authentication, ApiKeyDoc apiKeyDoc) { + assert authentication.getEffectiveSubject().getUser().principal().equals(apiKeyDoc.creator.get("principal")); + + if (apiKeyDoc.invalidated) { + throw new IllegalArgumentException("cannot update invalidated API key [" + apiKeyId + "]"); + } + + boolean expired = apiKeyDoc.expirationTime != -1 && clock.instant().isAfter(Instant.ofEpochMilli(apiKeyDoc.expirationTime)); + if (expired) { + throw new IllegalArgumentException("cannot update expired API key [" + apiKeyId + "]"); + } + + if (Strings.isNullOrEmpty(apiKeyDoc.name)) { + throw new IllegalArgumentException("cannot update legacy API key [" + apiKeyId + "] without name"); + } + } + /** * package-private for testing */ @@ -346,56 +419,72 @@ static XContentBuilder newDocument( Version version, @Nullable Map metadata ) throws IOException { - XContentBuilder builder = XContentFactory.jsonBuilder(); + final XContentBuilder builder = XContentFactory.jsonBuilder(); builder.startObject() .field("doc_type", "api_key") .field("creation_time", created.toEpochMilli()) .field("expiration_time", expiration == null ? null : expiration.toEpochMilli()) .field("api_key_invalidated", false); - byte[] utf8Bytes = null; - try { - utf8Bytes = CharArrays.toUtf8Bytes(apiKeyHashChars); - builder.field("api_key_hash").utf8Value(utf8Bytes, 0, utf8Bytes.length); - } finally { - if (utf8Bytes != null) { - Arrays.fill(utf8Bytes, (byte) 0); - } - } + addApiKeyHash(builder, apiKeyHashChars); + addRoleDescriptors(builder, keyRoles); + addLimitedByRoleDescriptors(builder, userRoles); - // Save role_descriptors - builder.startObject("role_descriptors"); - if (keyRoles != null && keyRoles.isEmpty() == false) { - for (RoleDescriptor descriptor : keyRoles) { - builder.field(descriptor.getName(), (contentBuilder, params) -> descriptor.toXContent(contentBuilder, params, true)); - } - } - builder.endObject(); + builder.field("name", name).field("version", version.id).field("metadata_flattened", metadata); + addCreator(builder, authentication); - // Save limited_by_role_descriptors - builder.startObject("limited_by_role_descriptors"); - for (RoleDescriptor descriptor : userRoles) { - builder.field(descriptor.getName(), (contentBuilder, params) -> descriptor.toXContent(contentBuilder, params, true)); + return builder.endObject(); + } + + static XContentBuilder buildUpdatedDocument( + final ApiKeyDoc currentApiKeyDoc, + final Authentication authentication, + final Set userRoles, + final List keyRoles, + final Version version, + final Map metadata + ) throws IOException { + final XContentBuilder builder = XContentFactory.jsonBuilder(); + builder.startObject() + .field("doc_type", "api_key") + .field("creation_time", currentApiKeyDoc.creationTime) + .field("expiration_time", currentApiKeyDoc.expirationTime == -1 ? null : currentApiKeyDoc.expirationTime) + .field("api_key_invalidated", false); + + addApiKeyHash(builder, currentApiKeyDoc.hash.toCharArray()); + + if (keyRoles != null) { + logger.trace(() -> format("Building API key doc with updated role descriptors [{}]", keyRoles)); + addRoleDescriptors(builder, keyRoles); + } else { + assert currentApiKeyDoc.roleDescriptorsBytes != null; + builder.rawField("role_descriptors", currentApiKeyDoc.roleDescriptorsBytes.streamInput(), XContentType.JSON); } - builder.endObject(); - builder.field("name", name).field("version", version.id).field("metadata_flattened", metadata); - { - builder.startObject("creator") - .field("principal", authentication.getUser().principal()) - .field("full_name", authentication.getUser().fullName()) - .field("email", authentication.getUser().email()) - .field("metadata", authentication.getUser().metadata()) - .field("realm", authentication.getSourceRealm().getName()) - .field("realm_type", authentication.getSourceRealm().getType()); - if (authentication.getSourceRealm().getDomain() != null) { - builder.field("realm_domain", authentication.getSourceRealm().getDomain()); - } - builder.endObject(); + addLimitedByRoleDescriptors(builder, userRoles); + + builder.field("name", currentApiKeyDoc.name).field("version", version.id); + + assert currentApiKeyDoc.metadataFlattened == null + || MetadataUtils.containsReservedMetadata( + XContentHelper.convertToMap(currentApiKeyDoc.metadataFlattened, false, XContentType.JSON).v2() + ) == false : "API key doc to be updated contains reserved metadata"; + if (metadata != null) { + logger.trace(() -> format("Building API key doc with updated metadata [{}]", metadata)); + builder.field("metadata_flattened", metadata); + } else { + builder.rawField( + "metadata_flattened", + currentApiKeyDoc.metadataFlattened == null + ? ApiKeyDoc.NULL_BYTES.streamInput() + : currentApiKeyDoc.metadataFlattened.streamInput(), + XContentType.JSON + ); } - builder.endObject(); - return builder; + addCreator(builder, authentication); + + return builder.endObject(); } void tryAuthenticate(ThreadContext ctx, ApiKeyCredentials credentials, ActionListener> listener) { @@ -908,6 +997,7 @@ public void invalidateApiKeys( apiKeyIds, true, false, + ApiKeyService::convertSearchHitToApiKeyInfo, ActionListener.wrap(apiKeys -> { if (apiKeys.isEmpty()) { logger.debug( @@ -919,10 +1009,7 @@ public void invalidateApiKeys( ); invalidateListener.onResponse(InvalidateApiKeyResponse.emptyResponse()); } else { - invalidateAllApiKeys( - apiKeys.stream().map(apiKey -> apiKey.getId()).collect(Collectors.toSet()), - invalidateListener - ); + invalidateAllApiKeys(apiKeys.stream().map(ApiKey::getId).collect(Collectors.toSet()), invalidateListener); } }, invalidateListener::onFailure) ); @@ -933,11 +1020,12 @@ private void invalidateAllApiKeys(Collection apiKeyIds, ActionListener void findApiKeys( final BoolQueryBuilder boolQuery, boolean filterOutInvalidatedKeys, boolean filterOutExpiredKeys, - ActionListener> listener + final Function hitParser, + final ActionListener> listener ) { if (filterOutInvalidatedKeys) { boolQuery.filter(QueryBuilders.termQuery("api_key_invalidated", false)); @@ -959,12 +1047,7 @@ private void findApiKeys( .request(); securityIndex.checkIndexVersionThenExecute( listener::onFailure, - () -> ScrollHelper.fetchAllByEntity( - client, - request, - new ContextPreservingActionListener<>(supplier, listener), - ApiKeyService::convertSearchHitToApiKeyInfo - ) + () -> ScrollHelper.fetchAllByEntity(client, request, new ContextPreservingActionListener<>(supplier, listener), hitParser) ); } } @@ -985,14 +1068,33 @@ public static QueryBuilder filterForRealmNames(String[] realmNames) { } } - private void findApiKeysForUserRealmApiKeyIdAndNameCombination( + private void findVersionedApiKeyDocsForSubject( + final Authentication authentication, + final String[] apiKeyIds, + final ActionListener> listener + ) { + assert authentication.isApiKey() == false; + findApiKeysForUserRealmApiKeyIdAndNameCombination( + getOwnersRealmNames(authentication), + authentication.getEffectiveSubject().getUser().principal(), + null, + apiKeyIds, + false, + false, + ApiKeyService::convertSearchHitToVersionedApiKeyDoc, + listener + ); + } + + private void findApiKeysForUserRealmApiKeyIdAndNameCombination( String[] realmNames, String userName, String apiKeyName, String[] apiKeyIds, boolean filterOutInvalidatedKeys, boolean filterOutExpiredKeys, - ActionListener> listener + Function hitParser, + ActionListener> listener ) { final SecurityIndexManager frozenSecurityIndex = securityIndex.freeze(); if (frozenSecurityIndex.indexExists() == false) { @@ -1019,7 +1121,7 @@ private void findApiKeysForUserRealmApiKeyIdAndNameCombination( boolQuery.filter(QueryBuilders.idsQuery().addIds(apiKeyIds)); } - findApiKeys(boolQuery, filterOutInvalidatedKeys, filterOutExpiredKeys, listener); + findApiKeys(boolQuery, filterOutInvalidatedKeys, filterOutExpiredKeys, hitParser, listener); } } @@ -1096,9 +1198,153 @@ private void indexInvalidation( } } + private void translateResponseAndClearCache( + final String apiKeyId, + final BulkResponse bulkResponse, + final ActionListener listener + ) { + final BulkItemResponse[] elements = bulkResponse.getItems(); + assert elements.length == 1 : "expected single item in bulk index response for API key update"; + final var bulkItemResponse = elements[0]; + if (bulkItemResponse.isFailed()) { + listener.onFailure(bulkItemResponse.getFailure().getCause()); + } else { + assert bulkItemResponse.getResponse().getId().equals(apiKeyId); + // Since we made an index request against an existing document, we can't get a NOOP or CREATED here + assert bulkItemResponse.getResponse().getResult() == DocWriteResponse.Result.UPDATED; + clearApiKeyDocCache(apiKeyId, new UpdateApiKeyResponse(true), listener); + } + } + + private static VersionedApiKeyDoc single(final String apiKeyId, final Collection elements) { + if (elements.size() != 1) { + final var message = "expected single API key doc with ID [" + + apiKeyId + + "] to be found for update but found [" + + elements.size() + + "]"; + assert false : message; + throw new IllegalStateException(message); + } + return elements.iterator().next(); + } + + private BulkRequest buildBulkRequestForUpdate( + final Collection currentVersionedDocs, + final Authentication authentication, + final UpdateApiKeyRequest request, + final Set userRoles + ) throws IOException { + assert currentVersionedDocs.isEmpty() == false; + final var targetDocVersion = clusterService.state().nodes().getMinNodeVersion(); + final var bulkRequestBuilder = client.prepareBulk(); + for (final VersionedApiKeyDoc apiKeyDoc : currentVersionedDocs) { + logger.trace( + "Building update request for API key doc [{}] with seqNo [{}] and primaryTerm [{}]", + request.getId(), + apiKeyDoc.seqNo(), + apiKeyDoc.primaryTerm() + ); + final var currentDocVersion = Version.fromId(apiKeyDoc.doc().version); + assert currentDocVersion.onOrBefore(targetDocVersion) : "current API key doc version must be on or before target version"; + if (currentDocVersion.before(targetDocVersion)) { + logger.debug( + "API key update for [{}] will update version from [{}] to [{}]", + request.getId(), + currentDocVersion, + targetDocVersion + ); + } + bulkRequestBuilder.add( + client.prepareIndex(SECURITY_MAIN_ALIAS) + .setId(request.getId()) + .setSource( + buildUpdatedDocument( + apiKeyDoc.doc(), + authentication, + userRoles, + request.getRoleDescriptors(), + targetDocVersion, + request.getMetadata() + ) + ) + .setIfSeqNo(apiKeyDoc.seqNo()) + .setIfPrimaryTerm(apiKeyDoc.primaryTerm()) + .setOpType(DocWriteRequest.OpType.INDEX) + .request() + ); + } + bulkRequestBuilder.setRefreshPolicy(RefreshPolicy.WAIT_UNTIL); + return bulkRequestBuilder.request(); + } + + private void executeBulkRequest(final BulkRequest bulkRequest, final ActionListener listener) { + securityIndex.prepareIndexIfNeededThenExecute( + listener::onFailure, + () -> executeAsyncWithOrigin(client.threadPool().getThreadContext(), SECURITY_ORIGIN, bulkRequest, listener, client::bulk) + ); + } + + private static void addLimitedByRoleDescriptors(final XContentBuilder builder, final Set userRoles) throws IOException { + assert userRoles != null; + builder.startObject("limited_by_role_descriptors"); + for (RoleDescriptor descriptor : userRoles) { + builder.field(descriptor.getName(), (contentBuilder, params) -> descriptor.toXContent(contentBuilder, params, true)); + } + builder.endObject(); + } + + private static void addApiKeyHash(final XContentBuilder builder, final char[] apiKeyHashChars) throws IOException { + byte[] utf8Bytes = null; + try { + utf8Bytes = CharArrays.toUtf8Bytes(apiKeyHashChars); + builder.field("api_key_hash").utf8Value(utf8Bytes, 0, utf8Bytes.length); + } finally { + if (utf8Bytes != null) { + Arrays.fill(utf8Bytes, (byte) 0); + } + } + } + + private static void addCreator(final XContentBuilder builder, final Authentication authentication) throws IOException { + final var user = authentication.getEffectiveSubject().getUser(); + final var sourceRealm = authentication.getEffectiveSubject().getRealm(); + builder.startObject("creator") + .field("principal", user.principal()) + .field("full_name", user.fullName()) + .field("email", user.email()) + .field("metadata", user.metadata()) + .field("realm", sourceRealm.getName()) + .field("realm_type", sourceRealm.getType()); + if (sourceRealm.getDomain() != null) { + builder.field("realm_domain", sourceRealm.getDomain()); + } + builder.endObject(); + } + + private static void addRoleDescriptors(final XContentBuilder builder, final List keyRoles) throws IOException { + builder.startObject("role_descriptors"); + if (keyRoles != null && keyRoles.isEmpty() == false) { + for (RoleDescriptor descriptor : keyRoles) { + builder.field(descriptor.getName(), (contentBuilder, params) -> descriptor.toXContent(contentBuilder, params, true)); + } + } + builder.endObject(); + } + private void clearCache(InvalidateApiKeyResponse result, ActionListener listener) { - final ClearSecurityCacheRequest clearApiKeyCacheRequest = new ClearSecurityCacheRequest().cacheName("api_key") - .keys(result.getInvalidatedApiKeys().toArray(String[]::new)); + executeClearCacheRequest( + result, + listener, + new ClearSecurityCacheRequest().cacheName("api_key").keys(result.getInvalidatedApiKeys().toArray(String[]::new)) + ); + } + + private void clearApiKeyDocCache(String apiKeyId, UpdateApiKeyResponse result, ActionListener listener) { + executeClearCacheRequest(result, listener, new ClearSecurityCacheRequest().cacheName("api_key_doc").keys(apiKeyId)); + } + + private void executeClearCacheRequest(T result, ActionListener listener, ClearSecurityCacheRequest clearApiKeyCacheRequest) { executeAsyncWithOrigin(client, SECURITY_ORIGIN, ClearSecurityCacheAction.INSTANCE, clearApiKeyCacheRequest, new ActionListener<>() { @Override public void onResponse(ClearSecurityCacheResponse nodes) { @@ -1107,7 +1353,7 @@ public void onResponse(ClearSecurityCacheResponse nodes) { @Override public void onFailure(Exception e) { - logger.error("unable to clear API key cache", e); + logger.error(() -> format("unable to clear API key cache [{}]", clearApiKeyCacheRequest.cacheName()), e); listener.onFailure(new ElasticsearchException("clearing the API key cache failed; please clear the caches manually", e)); } }); @@ -1193,6 +1439,7 @@ public void getApiKeys( apiKeyIds, false, false, + ApiKeyService::convertSearchHitToApiKeyInfo, ActionListener.wrap(apiKeyInfos -> { if (apiKeyInfos.isEmpty()) { logger.debug( @@ -1274,6 +1521,18 @@ private static ApiKey convertSearchHitToApiKeyInfo(SearchHit hit) { ); } + private static VersionedApiKeyDoc convertSearchHitToVersionedApiKeyDoc(SearchHit hit) { + try ( + XContentParser parser = XContentHelper.createParser(XContentParserConfiguration.EMPTY, hit.getSourceRef(), XContentType.JSON) + ) { + return new VersionedApiKeyDoc(ApiKeyDoc.fromXContent(parser), hit.getSeqNo(), hit.getPrimaryTerm()); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + private record VersionedApiKeyDoc(ApiKeyDoc doc, long seqNo, long primaryTerm) {} + private RemovalListener> getAuthCacheRemovalListener(int maximumWeight) { return notification -> { if (RemovalReason.EVICTED == notification.getRemovalReason() && getApiKeyAuthCache().count() >= maximumWeight) { @@ -1449,7 +1708,6 @@ public ApiKeyDoc( Map creator, @Nullable BytesReference metadataFlattened ) { - this.docType = docType; this.creationTime = creationTime; this.expirationTime = expirationTime; diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java index 338dea27b9936..1d16d28d99aa3 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java @@ -58,6 +58,7 @@ import org.elasticsearch.xcontent.NamedXContentRegistry; import org.elasticsearch.xcontent.ToXContent; import org.elasticsearch.xcontent.XContentBuilder; +import org.elasticsearch.xcontent.XContentParserConfiguration; import org.elasticsearch.xcontent.XContentType; import org.elasticsearch.xcontent.json.JsonXContent; import org.elasticsearch.xpack.core.XPackSettings; @@ -82,6 +83,7 @@ import org.elasticsearch.xpack.security.authc.ApiKeyService.ApiKeyCredentials; import org.elasticsearch.xpack.security.authc.ApiKeyService.ApiKeyDoc; import org.elasticsearch.xpack.security.authc.ApiKeyService.CachedApiKeyHashResult; +import org.elasticsearch.xpack.security.authz.RoleDescriptorTests; import org.elasticsearch.xpack.security.authz.store.NativePrivilegeStore; import org.elasticsearch.xpack.security.support.CacheInvalidatorRegistry; import org.elasticsearch.xpack.security.support.FeatureNotEnabledException; @@ -102,6 +104,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.Set; @@ -1639,6 +1642,121 @@ public void testApiKeyDocDeserialization() throws IOException { assertEquals("bar", ((Map) creator.get("metadata")).get("foo")); } + public void testValidateApiKeyDocBeforeUpdate() throws IOException { + final var apiKeyId = randomAlphaOfLength(12); + final var apiKey = randomAlphaOfLength(16); + final var hasher = getFastStoredHashAlgoForTests(); + final char[] hash = hasher.hash(new SecureString(apiKey.toCharArray())); + + final var apiKeyService = createApiKeyService(); + final var apiKeyDocWithNullName = buildApiKeyDoc(hash, -1, false, null, Version.V_8_2_0.id); + final var auth = Authentication.newRealmAuthentication( + new User("test_user", "role"), + new Authentication.RealmRef("realm1", "realm_type1", "node") + ); + + var ex = expectThrows( + IllegalArgumentException.class, + () -> apiKeyService.validateCurrentApiKeyDocForUpdate(apiKeyId, auth, apiKeyDocWithNullName) + ); + assertThat(ex.getMessage(), containsString("cannot update legacy API key [" + apiKeyId + "] without name")); + + final var apiKeyDocWithEmptyName = buildApiKeyDoc(hash, -1, false, "", Version.V_8_2_0.id); + ex = expectThrows( + IllegalArgumentException.class, + () -> apiKeyService.validateCurrentApiKeyDocForUpdate(apiKeyId, auth, apiKeyDocWithEmptyName) + ); + assertThat(ex.getMessage(), containsString("cannot update legacy API key [" + apiKeyId + "] without name")); + } + + public void testBuildUpdatedDocument() throws IOException { + final var apiKey = randomAlphaOfLength(16); + final var hasher = getFastStoredHashAlgoForTests(); + final char[] hash = hasher.hash(new SecureString(apiKey.toCharArray())); + + final var oldApiKeyDoc = buildApiKeyDoc(hash, randomBoolean() ? -1 : Instant.now().toEpochMilli(), false); + + final Set newUserRoles = randomBoolean() ? Set.of() : Set.of(RoleDescriptorTests.randomRoleDescriptor()); + + final boolean nullKeyRoles = randomBoolean(); + final List newKeyRoles; + if (nullKeyRoles) { + newKeyRoles = null; + } else { + newKeyRoles = List.of(RoleDescriptorTests.randomRoleDescriptor()); + } + + final var metadata = ApiKeyTests.randomMetadata(); + final var version = Version.CURRENT; + final var authentication = randomValueOtherThanMany( + Authentication::isApiKey, + () -> AuthenticationTestHelper.builder().user(new User("user", "role")).build(false) + ); + + final var keyDocSource = ApiKeyService.buildUpdatedDocument( + oldApiKeyDoc, + authentication, + newUserRoles, + newKeyRoles, + version, + metadata + ); + final var updatedApiKeyDoc = ApiKeyDoc.fromXContent( + XContentHelper.createParser(XContentParserConfiguration.EMPTY, BytesReference.bytes(keyDocSource), XContentType.JSON) + ); + + assertEquals(oldApiKeyDoc.docType, updatedApiKeyDoc.docType); + assertEquals(oldApiKeyDoc.name, updatedApiKeyDoc.name); + assertEquals(oldApiKeyDoc.hash, updatedApiKeyDoc.hash); + assertEquals(oldApiKeyDoc.expirationTime, updatedApiKeyDoc.expirationTime); + assertEquals(oldApiKeyDoc.creationTime, updatedApiKeyDoc.creationTime); + assertEquals(oldApiKeyDoc.invalidated, updatedApiKeyDoc.invalidated); + + final var service = createApiKeyService(Settings.EMPTY); + final var actualUserRoles = service.parseRoleDescriptorsBytes( + "", + updatedApiKeyDoc.limitedByRoleDescriptorsBytes, + RoleReference.ApiKeyRoleType.LIMITED_BY + ); + assertEquals(newUserRoles.size(), actualUserRoles.size()); + assertEquals(new HashSet<>(newUserRoles), new HashSet<>(actualUserRoles)); + + final var actualKeyRoles = service.parseRoleDescriptorsBytes( + "", + updatedApiKeyDoc.roleDescriptorsBytes, + RoleReference.ApiKeyRoleType.ASSIGNED + ); + if (nullKeyRoles) { + assertEquals( + service.parseRoleDescriptorsBytes("", oldApiKeyDoc.roleDescriptorsBytes, RoleReference.ApiKeyRoleType.ASSIGNED), + actualKeyRoles + ); + } else { + assertEquals(newKeyRoles.size(), actualKeyRoles.size()); + assertEquals(new HashSet<>(newKeyRoles), new HashSet<>(actualKeyRoles)); + } + if (metadata == null) { + assertEquals(oldApiKeyDoc.metadataFlattened, updatedApiKeyDoc.metadataFlattened); + } else { + assertEquals(metadata, XContentHelper.convertToMap(updatedApiKeyDoc.metadataFlattened, true, XContentType.JSON).v2()); + } + + assertEquals(authentication.getEffectiveSubject().getUser().principal(), updatedApiKeyDoc.creator.getOrDefault("principal", null)); + assertEquals(authentication.getEffectiveSubject().getUser().fullName(), updatedApiKeyDoc.creator.getOrDefault("fullName", null)); + assertEquals(authentication.getEffectiveSubject().getUser().email(), updatedApiKeyDoc.creator.getOrDefault("email", null)); + assertEquals(authentication.getEffectiveSubject().getUser().metadata(), updatedApiKeyDoc.creator.getOrDefault("metadata", null)); + RealmRef realm = authentication.getEffectiveSubject().getRealm(); + assertEquals(realm.getName(), updatedApiKeyDoc.creator.getOrDefault("realm", null)); + assertEquals(realm.getType(), updatedApiKeyDoc.creator.getOrDefault("realm_type", null)); + if (realm.getDomain() != null) { + @SuppressWarnings("unchecked") + final var actualDomain = (Map) updatedApiKeyDoc.creator.getOrDefault("realm_domain", null); + assertEquals(realm.getDomain().name(), actualDomain.get("name")); + } else { + assertFalse(updatedApiKeyDoc.creator.containsKey("realm_domain")); + } + } + public void testApiKeyDocDeserializationWithNullValues() throws IOException { final String apiKeyDocumentSource = """ { @@ -1857,6 +1975,14 @@ private void mockSourceDocument(String id, Map sourceMap) throws } private ApiKeyDoc buildApiKeyDoc(char[] hash, long expirationTime, boolean invalidated) throws IOException { + return buildApiKeyDoc(hash, expirationTime, invalidated, randomAlphaOfLength(12)); + } + + private ApiKeyDoc buildApiKeyDoc(char[] hash, long expirationTime, boolean invalidated, String name) throws IOException { + return buildApiKeyDoc(hash, expirationTime, invalidated, name, 0); + } + + private ApiKeyDoc buildApiKeyDoc(char[] hash, long expirationTime, boolean invalidated, String name, int version) throws IOException { final BytesReference metadataBytes = XContentTestUtils.convertToXContent(ApiKeyTests.randomMetadata(), XContentType.JSON); return new ApiKeyDoc( "api_key", @@ -1864,8 +1990,8 @@ private ApiKeyDoc buildApiKeyDoc(char[] hash, long expirationTime, boolean inval expirationTime, invalidated, new String(hash), - randomAlphaOfLength(12), - 0, + name, + version, new BytesArray("{\"a role\": {\"cluster\": [\"all\"]}}"), new BytesArray("{\"limited role\": {\"cluster\": [\"all\"]}}"), Map.of( diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/RoleDescriptorTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/RoleDescriptorTests.java index 7ad3bad40fa46..3c7d936fa114a 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/RoleDescriptorTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/RoleDescriptorTests.java @@ -626,7 +626,7 @@ public void testIsEmpty() { } } - private RoleDescriptor randomRoleDescriptor() { + public static RoleDescriptor randomRoleDescriptor() { final RoleDescriptor.IndicesPrivileges[] indexPrivileges = new RoleDescriptor.IndicesPrivileges[randomIntBetween(0, 3)]; for (int i = 0; i < indexPrivileges.length; i++) { final RoleDescriptor.IndicesPrivileges.Builder builder = RoleDescriptor.IndicesPrivileges.builder()