Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Invalidating cross cluster API keys requires manage_security #107411

Merged
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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"));
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a bug -- we need to return, if we call onFailure.

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));
}
}
Loading