Skip to content

Commit

Permalink
Invalidating cross cluster API keys requires manage_security (#107411)
Browse files Browse the repository at this point in the history
This PR updates the privilege model to require `manage_security` cluster
privilege to invalidate cross cluster API keys, to better match the
access requirements of the creation and update APIs. Requests made with
lower privileges will receive descriptive errors in the response payload
indicating failure to invalidate, for each cross cluster API key. There
are no changes to invalidating REST API keys, nor to the Query or Get
APIs.
  • Loading branch information
n1v0lg committed Apr 16, 2024
1 parent a11562c commit d851c93
Show file tree
Hide file tree
Showing 5 changed files with 379 additions and 41 deletions.
5 changes: 5 additions & 0 deletions docs/changelog/107411.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
pr: 107411
summary: Invalidating cross cluster API keys requires `manage_security`
area: Security
type: enhancement
issues: []
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Map<String, ?>> 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()) {
Expand All @@ -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<Map<String, ?>> 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<Map<String, ?>> 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<Map<String, ?>> 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<Map<String, ?>> 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<Map<String, ?>> 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<Map<String, ?>> 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<String> getErrorReasons(List<Map<String, ?>> 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);
Expand Down Expand Up @@ -1751,13 +1909,17 @@ private Response performRequestUsingRandomAuthMethod(final Request request) thro
}

private EncodedApiKey createApiKey(final String apiKeyName, final Map<String, Object> metadata) throws IOException {
return createApiKey(MANAGE_OWN_API_KEY_USER, apiKeyName, metadata);
}

private EncodedApiKey createApiKey(final String username, final String apiKeyName, final Map<String, Object> metadata)
throws IOException {
final Map<String, Object> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,62 +10,98 @@
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;
import org.elasticsearch.xpack.core.security.SecurityContext;
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<InvalidateApiKeyRequest, InvalidateApiKeyResponse> {

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>) 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<InvalidateApiKeyResponse> 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<HasPrivilegesResponse> 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));
}
}

0 comments on commit d851c93

Please sign in to comment.