Skip to content

Commit

Permalink
Support bulk updates of API keys (#88856)
Browse files Browse the repository at this point in the history
This PR adds a new API route to support bulk updates of API keys: 

`POST _security/api_key/_bulk_update`

The route takes a list of IDs (`ids`) of API keys to update, along
with the same request parameters as the single operation route:

- `role_descriptors` - The list of role descriptors specified for the
key. This is one of the two parts that determines an API key’s
privileges. 
- `metadata_flattened` - The searchable metadata associated
to an API key

Analogously to the single operation route, a call to `_bulk_update`
automatically updates the `limited_by_role_descriptors`, `creator`, and
`version` fields for each API key.

The implementation ports the single API key update operation to use the
new bulk functionality under the hood, translating as necessary at the
transport layer.

Relates: #88758
  • Loading branch information
n1v0lg committed Aug 1, 2022
1 parent 516f2fb commit d2868b0
Show file tree
Hide file tree
Showing 24 changed files with 1,446 additions and 288 deletions.
5 changes: 5 additions & 0 deletions docs/changelog/88856.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
pr: 88856
summary: Support bulk updates of API keys
area: Security
type: feature
issues: []
Original file line number Diff line number Diff line change
Expand Up @@ -1268,6 +1268,10 @@ public static <T> List<T> randomSubsetOf(int size, Collection<T> collection) {
return tempList.subList(0, size);
}

public static <T> List<T> shuffledList(List<T> list) {
return randomSubsetOf(list.size(), list);
}

/**
* Builds a set of unique items. Usually you'll get the requested count but you might get less than that number if the supplier returns
* lots of repeats. Make sure that the items properly implement equals and hashcode.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* 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 static org.elasticsearch.action.ValidateActions.addValidationError;

public abstract class BaseUpdateApiKeyRequest extends ActionRequest {

@Nullable
protected final List<RoleDescriptor> roleDescriptors;
@Nullable
protected final Map<String, Object> metadata;

public BaseUpdateApiKeyRequest(@Nullable final List<RoleDescriptor> roleDescriptors, @Nullable final Map<String, Object> metadata) {
this.roleDescriptors = roleDescriptors;
this.metadata = metadata;
}

public BaseUpdateApiKeyRequest(StreamInput in) throws IOException {
super(in);
this.roleDescriptors = in.readOptionalList(RoleDescriptor::new);
this.metadata = in.readMap();
}

public Map<String, Object> getMetadata() {
return metadata;
}

public List<RoleDescriptor> getRoleDescriptors() {
return roleDescriptors;
}

@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.writeOptionalCollection(roleDescriptors);
out.writeGenericMap(metadata);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*
* 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.ActionType;

public final class BulkUpdateApiKeyAction extends ActionType<BulkUpdateApiKeyResponse> {

public static final String NAME = "cluster:admin/xpack/security/api_key/bulk_update";
public static final BulkUpdateApiKeyAction INSTANCE = new BulkUpdateApiKeyAction();

private BulkUpdateApiKeyAction() {
super(NAME, BulkUpdateApiKeyResponse::new);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* 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.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.authz.RoleDescriptor;

import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Objects;

import static org.elasticsearch.action.ValidateActions.addValidationError;

public final class BulkUpdateApiKeyRequest extends BaseUpdateApiKeyRequest {

public static BulkUpdateApiKeyRequest usingApiKeyIds(String... ids) {
return new BulkUpdateApiKeyRequest(Arrays.stream(ids).toList(), null, null);
}

public static BulkUpdateApiKeyRequest wrap(final UpdateApiKeyRequest request) {
return new BulkUpdateApiKeyRequest(List.of(request.getId()), request.getRoleDescriptors(), request.getMetadata());
}

private final List<String> ids;

public BulkUpdateApiKeyRequest(
final List<String> ids,
@Nullable final List<RoleDescriptor> roleDescriptors,
@Nullable final Map<String, Object> metadata
) {
super(roleDescriptors, metadata);
this.ids = Objects.requireNonNull(ids, "API key IDs must not be null");
}

public BulkUpdateApiKeyRequest(StreamInput in) throws IOException {
super(in);
this.ids = in.readStringList();
}

@Override
public ActionRequestValidationException validate() {
ActionRequestValidationException validationException = super.validate();
if (ids.isEmpty()) {
validationException = addValidationError("Field [ids] cannot be empty", validationException);
}
return validationException;
}

@Override
public void writeTo(StreamOutput out) throws IOException {
super.writeTo(out);
out.writeStringCollection(ids);
}

public List<String> getIds() {
return ids;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/*
* 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.ElasticsearchException;
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.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public final class BulkUpdateApiKeyResponse extends ActionResponse implements ToXContentObject, Writeable {

private final List<String> updated;
private final List<String> noops;
private final Map<String, Exception> errorDetails;

public BulkUpdateApiKeyResponse(final List<String> updated, final List<String> noops, final Map<String, Exception> errorDetails) {
this.updated = updated;
this.noops = noops;
this.errorDetails = errorDetails;
}

public BulkUpdateApiKeyResponse(StreamInput in) throws IOException {
super(in);
this.updated = in.readStringList();
this.noops = in.readStringList();
this.errorDetails = in.readMap(StreamInput::readString, StreamInput::readException);
}

public List<String> getUpdated() {
return updated;
}

public List<String> getNoops() {
return noops;
}

public Map<String, Exception> getErrorDetails() {
return errorDetails;
}

public int getTotalResultCount() {
return updated.size() + noops.size() + errorDetails.size();
}

@Override
public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
builder.startObject().stringListField("updated", updated).stringListField("noops", noops);
if (errorDetails.isEmpty() == false) {
builder.startObject("errors");
{
builder.field("count", errorDetails.size());
builder.startObject("details");
for (Map.Entry<String, Exception> idWithException : errorDetails.entrySet()) {
builder.startObject(idWithException.getKey());
ElasticsearchException.generateThrowableXContent(builder, params, idWithException.getValue());
builder.endObject();
}
builder.endObject();
}
builder.endObject();
}
return builder.endObject();
}

@Override
public void writeTo(StreamOutput out) throws IOException {
out.writeStringCollection(updated);
out.writeStringCollection(noops);
out.writeMap(errorDetails, StreamOutput::writeString, StreamOutput::writeException);
}

@Override
public String toString() {
return "BulkUpdateApiKeyResponse{" + "updated=" + updated + ", noops=" + noops + ", errorDetails=" + errorDetails + '}';
}

public static Builder builder() {
return new Builder();
}

public static class Builder {
private final List<String> updated;
private final List<String> noops;
private final Map<String, Exception> errorDetails;

public Builder() {
updated = new ArrayList<>();
noops = new ArrayList<>();
errorDetails = new HashMap<>();
}

public Builder updated(final String id) {
updated.add(id);
return this;
}

public Builder noop(final String id) {
noops.add(id);
return this;
}

public Builder error(final String id, final Exception ex) {
errorDetails.put(id, ex);
return this;
}

public BulkUpdateApiKeyResponse build() {
return new BulkUpdateApiKeyResponse(updated, noops, errorDetails);
}
}
}

0 comments on commit d2868b0

Please sign in to comment.