Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/*
* 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.TransportVersion;
import org.elasticsearch.action.ActionRequest;
import org.elasticsearch.action.ActionRequestValidationException;
import org.elasticsearch.action.support.WriteRequest;
import org.elasticsearch.common.Strings;
import org.elasticsearch.common.UUIDs;
import org.elasticsearch.common.io.stream.StreamInput;
import org.elasticsearch.common.io.stream.StreamOutput;
import org.elasticsearch.core.TimeValue;
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.Collections;
import java.util.List;
import java.util.Map;

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

public abstract class AbstractCreateApiKeyRequest extends ActionRequest {
public static final WriteRequest.RefreshPolicy DEFAULT_REFRESH_POLICY = WriteRequest.RefreshPolicy.WAIT_UNTIL;
protected final String id;
protected String name;
protected TimeValue expiration;
protected Map<String, Object> metadata;
protected List<RoleDescriptor> roleDescriptors = Collections.emptyList();
protected WriteRequest.RefreshPolicy refreshPolicy = DEFAULT_REFRESH_POLICY;

public AbstractCreateApiKeyRequest() {
super();
// we generate the API key id soonest so it's part of the request body so it is audited
this.id = UUIDs.base64UUID(); // because auditing can currently only catch requests but not responses,
}

public AbstractCreateApiKeyRequest(StreamInput in) throws IOException {
super(in);
if (in.getTransportVersion().onOrAfter(TransportVersion.V_7_10_0)) {
this.id = in.readString();
} else {
this.id = UUIDs.base64UUID();
}
if (in.getTransportVersion().onOrAfter(TransportVersion.V_7_5_0)) {
this.name = in.readOptionalString();
} else {
this.name = in.readString();
}
this.expiration = in.readOptionalTimeValue();
this.roleDescriptors = in.readImmutableList(RoleDescriptor::new);
this.refreshPolicy = WriteRequest.RefreshPolicy.readFrom(in);
if (in.getTransportVersion().onOrAfter(TransportVersion.V_8_0_0)) {
this.metadata = in.readMap();
} else {
this.metadata = null;
}
}

public String getId() {
return id;
}

public String getName() {
return name;
}

public abstract ApiKey.Type getType();

public TimeValue getExpiration() {
return expiration;
}

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

public WriteRequest.RefreshPolicy getRefreshPolicy() {
return refreshPolicy;
}

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

@Override
public ActionRequestValidationException validate() {
ActionRequestValidationException validationException = null;
if (Strings.isNullOrEmpty(name)) {
validationException = addValidationError("api key name is required", validationException);
} else {
if (name.length() > 256) {
validationException = addValidationError("api key name may not be more than 256 characters long", validationException);
}
if (name.equals(name.trim()) == false) {
validationException = addValidationError("api key name may not begin or end with whitespace", validationException);
}
if (name.startsWith("_")) {
validationException = addValidationError("api key name may not begin with an underscore", validationException);
}
}
if (metadata != null && MetadataUtils.containsReservedMetadata(metadata)) {
validationException = addValidationError(
"API key metadata keys may not start with [" + MetadataUtils.RESERVED_PREFIX + "]",
validationException
);
}
for (RoleDescriptor roleDescriptor : getRoleDescriptors()) {
validationException = RoleDescriptorRequestValidator.validate(roleDescriptor, validationException);
}
return validationException;
}

@Override
public void writeTo(StreamOutput out) throws IOException {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm wondering if the cross cluster class should override this and throw if the target version is too old (similar to what we do we e.g., PutRoleRequest). This should never happen in practice so I don't feel strongly.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

No because we have a new action here. Since the action won't be available in an old node, the code will fail before it even attempts to decode the request. It's like when we add any other new TransportAction, we don't need any BWC handling for its request class.

Strictly speaking, the new CreateCrossApiKeyRequest can override this method to drop existing checks for old verions, e.g.:

if (out.getTransportVersion().onOrAfter(TransportVersion.V_7_5_0)) {
    out.writeOptionalString(name);
} else {
    out.writeString(name);
}

But that means duplicating a bunch code (for both read and write) in subclasses. So I didn't do it since the benefit is rather marginal.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

But that means duplicating a bunch code (for both read and write) in subclasses. So I didn't do it since the benefit is rather marginal.

On a second thought, I decided to have overridden writeTo and corresponding constructor for the new Request and drop these obsolete version checks. Since we are introducing a new class, might as well takes more advantage of it.

super.writeTo(out);
if (out.getTransportVersion().onOrAfter(TransportVersion.V_7_10_0)) {
out.writeString(id);
}
if (out.getTransportVersion().onOrAfter(TransportVersion.V_7_5_0)) {
out.writeOptionalString(name);
} else {
out.writeString(name);
}
out.writeOptionalTimeValue(expiration);
out.writeList(getRoleDescriptors());
refreshPolicy.writeTo(out);
if (out.getTransportVersion().onOrAfter(TransportVersion.V_7_13_0)) {
out.writeGenericMap(metadata);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import java.io.IOException;
import java.time.Instant;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
Expand All @@ -36,6 +37,29 @@
*/
public final class ApiKey implements ToXContentObject, Writeable {

public enum Type {
/**
* REST type API keys can authenticate on the HTTP interface
*/
REST,
/**
* Cross cluster type API keys can authenticate on the dedicated remote cluster server interface
*/
CROSS_CLUSTER;

public static Type parse(String value) {
return switch (value.toLowerCase(Locale.ROOT)) {
case "rest" -> REST;
case "cross_cluster" -> CROSS_CLUSTER;
default -> throw new IllegalArgumentException("unknown API key type [" + value + "]");
};
}

public String value() {
return name().toLowerCase(Locale.ROOT);
}
}

private final String name;
private final String id;
private final Instant creation;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,46 +7,25 @@

package org.elasticsearch.xpack.core.security.action.apikey;

import org.elasticsearch.TransportVersion;
import org.elasticsearch.action.ActionRequest;
import org.elasticsearch.action.ActionRequestValidationException;
import org.elasticsearch.action.support.WriteRequest;
import org.elasticsearch.common.Strings;
import org.elasticsearch.common.UUIDs;
import org.elasticsearch.common.io.stream.StreamInput;
import org.elasticsearch.common.io.stream.StreamOutput;
import org.elasticsearch.core.Nullable;
import org.elasticsearch.core.TimeValue;
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.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;

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

/**
* Request class used for the creation of an API key. The request requires a name to be provided
* and optionally an expiration time and permission limitation can be provided.
*/
public final class CreateApiKeyRequest extends ActionRequest {
public static final WriteRequest.RefreshPolicy DEFAULT_REFRESH_POLICY = WriteRequest.RefreshPolicy.WAIT_UNTIL;

private final String id;
private String name;
private TimeValue expiration;
private Map<String, Object> metadata;
private List<RoleDescriptor> roleDescriptors = Collections.emptyList();
private WriteRequest.RefreshPolicy refreshPolicy = DEFAULT_REFRESH_POLICY;
public final class CreateApiKeyRequest extends AbstractCreateApiKeyRequest {

public CreateApiKeyRequest() {
super();
this.id = UUIDs.base64UUID(); // because auditing can currently only catch requests but not responses,
// we generate the API key id soonest so it's part of the request body so it is audited
}

/**
Expand Down Expand Up @@ -74,118 +53,35 @@ public CreateApiKeyRequest(

public CreateApiKeyRequest(StreamInput in) throws IOException {
super(in);
if (in.getTransportVersion().onOrAfter(TransportVersion.V_7_10_0)) {
this.id = in.readString();
} else {
this.id = UUIDs.base64UUID();
}
if (in.getTransportVersion().onOrAfter(TransportVersion.V_7_5_0)) {
this.name = in.readOptionalString();
} else {
this.name = in.readString();
}
this.expiration = in.readOptionalTimeValue();
this.roleDescriptors = in.readImmutableList(RoleDescriptor::new);
this.refreshPolicy = WriteRequest.RefreshPolicy.readFrom(in);
if (in.getTransportVersion().onOrAfter(TransportVersion.V_8_0_0)) {
this.metadata = in.readMap();
} else {
this.metadata = null;
}
}

public String getId() {
return id;
@Override
public ApiKey.Type getType() {
return ApiKey.Type.REST;
}

public void setId() {
throw new UnsupportedOperationException("The API Key Id cannot be set, it must be generated randomly");
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public TimeValue getExpiration() {
return expiration;
}

public void setExpiration(@Nullable TimeValue expiration) {
this.expiration = expiration;
}

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

public void setRoleDescriptors(@Nullable List<RoleDescriptor> roleDescriptors) {
this.roleDescriptors = (roleDescriptors == null) ? List.of() : List.copyOf(roleDescriptors);
}

public WriteRequest.RefreshPolicy getRefreshPolicy() {
return refreshPolicy;
}

public void setRefreshPolicy(WriteRequest.RefreshPolicy refreshPolicy) {
this.refreshPolicy = Objects.requireNonNull(refreshPolicy, "refresh policy may not be null");
}

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

public void setMetadata(Map<String, Object> metadata) {
this.metadata = metadata;
}

@Override
public ActionRequestValidationException validate() {
ActionRequestValidationException validationException = null;
if (Strings.isNullOrEmpty(name)) {
validationException = addValidationError("api key name is required", validationException);
} else {
if (name.length() > 256) {
validationException = addValidationError("api key name may not be more than 256 characters long", validationException);
}
if (name.equals(name.trim()) == false) {
validationException = addValidationError("api key name may not begin or end with whitespace", validationException);
}
if (name.startsWith("_")) {
validationException = addValidationError("api key name may not begin with an underscore", validationException);
}
}
if (metadata != null && MetadataUtils.containsReservedMetadata(metadata)) {
validationException = addValidationError(
"API key metadata keys may not start with [" + MetadataUtils.RESERVED_PREFIX + "]",
validationException
);
}
for (RoleDescriptor roleDescriptor : roleDescriptors) {
validationException = RoleDescriptorRequestValidator.validate(roleDescriptor, validationException);
}
return validationException;
}

@Override
public void writeTo(StreamOutput out) throws IOException {
super.writeTo(out);
if (out.getTransportVersion().onOrAfter(TransportVersion.V_7_10_0)) {
out.writeString(id);
}
if (out.getTransportVersion().onOrAfter(TransportVersion.V_7_5_0)) {
out.writeOptionalString(name);
} else {
out.writeString(name);
}
out.writeOptionalTimeValue(expiration);
out.writeList(roleDescriptors);
refreshPolicy.writeTo(out);
if (out.getTransportVersion().onOrAfter(TransportVersion.V_7_13_0)) {
out.writeGenericMap(metadata);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* 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;

/**
* ActionType for the creation of a cross-cluster API key
*/
public final class CreateCrossClusterApiKeyAction extends ActionType<CreateApiKeyResponse> {

public static final String NAME = "cluster:admin/xpack/security/cross_cluster/api_key/create";
public static final CreateCrossClusterApiKeyAction INSTANCE = new CreateCrossClusterApiKeyAction();

private CreateCrossClusterApiKeyAction() {
super(NAME, CreateApiKeyResponse::new);
}

}
Loading