blob: 3941388826b240570d5c280608629cb8c471363b [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.solr.encryption;
import org.apache.lucene.index.SegmentCommitInfo;
import org.apache.lucene.index.SegmentInfos;
import org.apache.lucene.store.Directory;
import org.apache.solr.common.SolrException;
import org.apache.solr.common.util.ExecutorUtil;
import org.apache.solr.core.DirectoryFactory;
import org.apache.solr.core.SolrCore;
import org.apache.solr.handler.RequestHandlerBase;
import org.apache.solr.request.SolrQueryRequest;
import org.apache.solr.response.SolrQueryResponse;
import org.apache.solr.security.AuthorizationContext;
import org.apache.solr.security.PermissionNameProvider;
import org.apache.solr.update.CommitUpdateCommand;
import org.apache.solr.update.UpdateHandler;
import org.apache.solr.update.UpdateLog;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.Nullable;
import java.io.IOException;
import java.lang.invoke.MethodHandles;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ThreadFactory;
import static org.apache.solr.encryption.CommitUtil.readLatestCommit;
import static org.apache.solr.encryption.EncryptionUtil.*;
/**
* Handles an encryption request for a specific {@link SolrCore}.
* <p>
* The caller provides the mandatory {@link #PARAM_KEY_ID} request parameter to define the encryption
* key id to use to encrypt the index files. To decrypt the index to cleartext, the special parameter
* value {@link #NO_KEY_ID} must be provided.
* <p>
* The encryption processing is asynchronous. The request returns immediately with two response
* parameters. {@link #ENCRYPTION_STATE} parameter with values {@link #STATE_PENDING},
* {@link #STATE_COMPLETE}, or {@link #STATE_BUSY}. And {@link #STATUS} parameter with values
* {@link #STATUS_SUCCESS} or {@link #STATUS_FAILURE}.
* <p>
* The expected usage of this handler is to first send an encryption request with a key id, and
* receive a response with {@link #STATUS_SUCCESS} and a {@link #STATE_PENDING}. If the caller needs
* to know when the encryption is complete, it can (optionally) repeatedly send the same encryption
* request with the same key id, until it receives a response with {@link #STATUS_SUCCESS} and a
* {@link #STATE_COMPLETE}.
* <p>
* If the handler returns a response with {@link #STATE_BUSY}, it means that another encryption for a
* different key id is ongoing on the same Solr core. It cannot start a new encryption until it finishes.
* <p>
* If the handler returns a response with {@link #STATUS_FAILURE}, it means the request did not succeed
* and should be retried by the caller (there should be error logs).
*/
public class EncryptionRequestHandler extends RequestHandlerBase {
private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
/**
* Key id request parameter - required.
* Its value should be {@link #NO_KEY_ID} for no key.
*/
public static final String PARAM_KEY_ID = "encryptionKeyId";
/**
* Value of the key id parameter meaning there is no key and no encryption.
*/
public static final String NO_KEY_ID = "no_key_id";
/**
* Encryption pending boolean parameter, in the commit user data.
* When present and true, this means the encryption process is still pending.
* It includes the {@link EncryptionUpdateHandler#TRANSFERABLE_COMMIT_DATA} prefix to be transferred from a
* commit to the next one automatically.
*/
private static final String COMMIT_ENCRYPTION_PENDING = COMMIT_CRYPTO + "encryptionPending";
/**
* Status of the request.
*/
public static final String STATUS = "status";
/**
* One of {@link #STATUS} values: the request was processed successfully. Get additional information
* with the {@link #ENCRYPTION_STATE} response parameter.
*/
public static final String STATUS_SUCCESS = "success";
/**
* One of {@link #STATUS} values: the request was not processed correctly, an error occurred.
*/
public static final String STATUS_FAILURE = "failure";
/**
* Response parameter name to provide the status of the encryption.
*/
public static final String ENCRYPTION_STATE = "encryptionState";
/**
* One of {@link #ENCRYPTION_STATE} values: the encryption with the provided key id is ongoing and pending.
*/
public static final String STATE_PENDING = "pending";
/**
* One of {@link #ENCRYPTION_STATE} values: the encryption with the provided key id is complete.
*/
public static final String STATE_COMPLETE = "complete";
/**
* One of {@link #ENCRYPTION_STATE} values: another encryption for a different key id is ongoing
* on the same Solr core; cannot start a new encryption until it finishes.
*/
public static final String STATE_BUSY = "busy";
private static final Object pendingEncryptionLock = new Object();
private static final Map<String, PendingKeyId> pendingEncryptions = new HashMap<>();
private final ExecutorService executor = ExecutorUtil.newMDCAwareFixedThreadPool(4, new ThreadFactory() {
private int threadNum;
@Override
public synchronized Thread newThread(Runnable r) {
Thread t = new Thread(r, "Encryption-" + threadNum++);
t.setDaemon(true);
return t;
}
});
/**
* Builds the key cookie based on the request.
* After this method returns, the returned key cookie parameters will be stored in the index metadata along with the
* key id. Later the key cookie will be passed to
* {@link KeySupplier#getKeySecret(String, java.util.function.Function)} to get the key secret.
* <p>
* The default behavior is to call {@link KeySupplier#getKeyCookie(String, Map)} with no parameters.
* Override this method to provide specific parameters to the {@link KeySupplier}.
* <p>
* If a required param is missing, this method should throw a {@link SolrException} with
* {@link SolrException.ErrorCode#BAD_REQUEST} and should set the response status to {@link #STATUS_FAILURE}.
*
* @return the cookie parameters. The returned map is considered immutable and is never modified. This method may
* return null or an empty map if no parameters.
*/
@Nullable
protected Map<String, String> buildKeyCookie(String keyId,
SolrQueryRequest req,
SolrQueryResponse rsp) throws IOException {
KeySupplier keySupplier = EncryptionDirectoryFactory.getFactory(req.getCore()).getKeySupplier();
return keySupplier.getKeyCookie(keyId, null);
}
@Override
public void close() throws IOException {
try {
ExecutorUtil.shutdownAndAwaitTermination(executor);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
super.close();
}
}
@Override
public String getDescription() {
return "Handles encryption requests";
}
@Override
public Name getPermissionName(AuthorizationContext request) {
return PermissionNameProvider.Name.CORE_EDIT_PERM;
}
@Override
public void handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp) throws Exception {
long startTimeMs = System.currentTimeMillis();
String keyId = req.getParams().get(PARAM_KEY_ID);
if (keyId == null || keyId.isEmpty()) {
rsp.add(STATUS, STATUS_FAILURE);
throw new IOException("Parameter " + PARAM_KEY_ID + " must be present and not empty."
+ " Use [" + PARAM_KEY_ID + "=\"" + NO_KEY_ID + "\"] for explicit decryption.");
} else if (keyId.equals(NO_KEY_ID)) {
keyId = null;
}
EncryptionDirectoryFactory.getFactory(req.getCore());
log.debug("Encrypt request for keyId={}", keyId);
boolean success = false;
String encryptionState = STATE_PENDING;
try {
SegmentInfos segmentInfos = readLatestCommit(req.getCore());
if (segmentInfos.size() == 0) {
commitEmptyIndexForEncryption(keyId, segmentInfos, req, rsp);
encryptionState = STATE_COMPLETE;
success = true;
return;
}
boolean encryptionComplete = false;
if (isCommitActiveKeyId(keyId, segmentInfos)) {
log.debug("Provided keyId={} is the current active key id", keyId);
if (Boolean.parseBoolean(segmentInfos.getUserData().get(COMMIT_ENCRYPTION_PENDING))) {
encryptionComplete = areAllSegmentsEncryptedWithKeyId(keyId, req.getCore(), segmentInfos)
&& areAllLogsEncryptedWithKeyId(keyId, req.getCore(), segmentInfos);
if (encryptionComplete) {
commitEncryptionComplete(keyId, segmentInfos, req);
}
} else {
encryptionComplete = true;
}
}
if (encryptionComplete) {
encryptionState = STATE_COMPLETE;
success = true;
return;
}
synchronized (pendingEncryptionLock) {
PendingKeyId pendingKeyId = pendingEncryptions.get(req.getCore().getName());
if (pendingKeyId != null) {
if (Objects.equals(pendingKeyId.keyId, keyId)) {
log.debug("Ongoing encryption for keyId={}", keyId);
encryptionState = STATE_PENDING;
success = true;
} else {
log.debug("Core busy encrypting for keyId={} different than requested keyId={}",
pendingKeyId.keyId, keyId);
encryptionState = STATE_BUSY;
}
return;
}
pendingEncryptions.put(req.getCore().getName(), new PendingKeyId(keyId));
}
try {
commitEncryptionStart(keyId, segmentInfos, req, rsp);
encryptAsync(req, startTimeMs);
success = true;
} finally {
if (!success) {
synchronized (pendingEncryptionLock) {
pendingEncryptions.remove(req.getCore().getName());
}
}
}
} finally {
if (success) {
rsp.add(STATUS, STATUS_SUCCESS);
} else {
rsp.add(STATUS, STATUS_FAILURE);
}
log.info("Responding encryption state={} success={} for keyId={}",
encryptionState, success, keyId);
rsp.add(ENCRYPTION_STATE, encryptionState);
}
}
private void commitEmptyIndexForEncryption(@Nullable String keyId,
SegmentInfos segmentInfos,
SolrQueryRequest req,
SolrQueryResponse rsp) throws IOException {
// Commit no change, with the new active key id in the commit user data.
log.debug("Commit on empty index for keyId={}", keyId);
CommitUpdateCommand commitCmd = new CommitUpdateCommand(req, false);
commitCmd.commitData = new HashMap<>(segmentInfos.getUserData());
commitCmd.commitData.remove(COMMIT_ENCRYPTION_PENDING);
setNewActiveKeyIdInCommit(keyId, commitCmd, rsp);
assert !commitCmd.commitData.isEmpty();
req.getCore().getUpdateHandler().commit(commitCmd);
}
private void setNewActiveKeyIdInCommit(String keyId,
CommitUpdateCommand commitCmd,
SolrQueryResponse rsp) throws IOException {
if (keyId == null) {
removeActiveKeyRefFromCommit(commitCmd.commitData);
ensureNonEmptyCommitDataForEmptyCommit(commitCmd.commitData);
} else {
Map<String, String> keyCookie = buildKeyCookie(keyId, commitCmd.getReq(), rsp);
EncryptionUtil.setNewActiveKeyIdInCommit(keyId, keyCookie, commitCmd.commitData);
}
}
private void commitEncryptionComplete(String keyId,
SegmentInfos segmentInfos,
SolrQueryRequest req) throws IOException {
assert isCommitActiveKeyId(keyId, segmentInfos);
log.debug("Commit encryption complete for keyId={}", keyId);
CommitUpdateCommand commitCmd = new CommitUpdateCommand(req, false);
commitCmd.commitData = new HashMap<>(segmentInfos.getUserData());
commitCmd.commitData.remove(COMMIT_ENCRYPTION_PENDING);
// All segments are encrypted with the key id,
// clear the oldest inactive key ids from the commit user data.
clearOldInactiveKeyIdsFromCommit(commitCmd.commitData);
ensureNonEmptyCommitDataForEmptyCommit(commitCmd.commitData);
req.getCore().getUpdateHandler().commit(commitCmd);
}
private void ensureNonEmptyCommitDataForEmptyCommit(Map<String, String> commitData) {
if (commitData.isEmpty()) {
// Ensure that there is some data in the commit user data so that an empty commit
// (with no change) is allowed.
commitData.put("crypto.cleartext", "true");
}
}
private void commitEncryptionStart(String keyId,
SegmentInfos segmentInfos,
SolrQueryRequest req,
SolrQueryResponse rsp) throws IOException {
log.debug("Commit encryption starting for keyId={}", keyId);
CommitUpdateCommand commitCmd = new CommitUpdateCommand(req, false);
commitCmd.commitData = new HashMap<>(segmentInfos.getUserData());
commitCmd.commitData.put(COMMIT_ENCRYPTION_PENDING, "true");
setNewActiveKeyIdInCommit(keyId, commitCmd, rsp);
req.getCore().getUpdateHandler().commit(commitCmd);
}
private void encryptAsync(SolrQueryRequest req, long startTimeMs) {
log.debug("Submitting async encryption");
executor.submit(() -> {
try {
EncryptionUpdateLog updateLog = getEncryptionUpdateLog(req.getCore());
if (updateLog != null) {
log.debug("Encrypting update log");
boolean logEncryptionComplete = updateLog.encryptLogs();
log.info("{} encrypted the update log in {}",
logEncryptionComplete ? "Successfully" : "Partially", elapsedTime(startTimeMs));
// If the logs encryption is not complete, it means some logs are currently in use.
// The encryption will be automatically be retried after the next commit which should
// release the old transaction log and make it ready for encryption.
}
log.debug("Triggering index encryption");
CommitUpdateCommand commitCmd = new CommitUpdateCommand(req, true);
// Trigger EncryptionMergePolicy.findForcedMerges() to re-encrypt
// each segment which is not encrypted with the latest active key id.
commitCmd.maxOptimizeSegments = Integer.MAX_VALUE;
req.getCore().getUpdateHandler().commit(commitCmd);
log.info("Successfully triggered index encryption with commit in {}", elapsedTime(startTimeMs));
} catch (IOException e) {
log.error("Exception while encrypting the index after {}", elapsedTime(startTimeMs), e);
} finally {
synchronized (pendingEncryptionLock) {
pendingEncryptions.remove(req.getCore().getName());
}
}
return null;
});
}
private boolean areAllSegmentsEncryptedWithKeyId(@Nullable String keyId,
SolrCore core,
SegmentInfos segmentInfos) throws IOException {
DirectoryFactory directoryFactory = core.getDirectoryFactory();
Directory indexDir = directoryFactory.get(core.getIndexDir(),
DirectoryFactory.DirContext.DEFAULT,
DirectoryFactory.LOCK_TYPE_NONE);
try {
EncryptionDirectory dir = (EncryptionDirectory) indexDir;
List<SegmentCommitInfo> segmentsWithOldKeyId = dir.getSegmentsWithOldKeyId(segmentInfos, keyId);
log.debug("Encryption is pending; {} segments do not have keyId={}",
segmentsWithOldKeyId.size(), keyId);
return segmentsWithOldKeyId.isEmpty();
} finally {
directoryFactory.release(indexDir);
}
}
private boolean areAllLogsEncryptedWithKeyId(String keyId, SolrCore core, SegmentInfos segmentInfos)
throws IOException {
EncryptionUpdateLog updateLog = getEncryptionUpdateLog(core);
return updateLog == null || updateLog.areAllLogsEncryptedWithKeyId(keyId, segmentInfos.getUserData());
}
private EncryptionUpdateLog getEncryptionUpdateLog(SolrCore core) {
UpdateHandler updateHandler = core.getUpdateHandler();
if (updateHandler == null) {
return null;
}
if (!(updateHandler.getUpdateLog() instanceof EncryptionUpdateLog)) {
throw new SolrException(SolrException.ErrorCode.SERVICE_UNAVAILABLE,
UpdateLog.class.getSimpleName()
+ " must be configured with an "
+ EncryptionUpdateLog.class.getSimpleName());
}
return (EncryptionUpdateLog) updateHandler.getUpdateLog();
}
private boolean isCommitActiveKeyId(String keyId, SegmentInfos segmentInfos) {
String keyRef = getActiveKeyRefFromCommit(segmentInfos.getUserData());
String activeKeyId = keyRef == null ? null : getKeyIdFromCommit(keyRef, segmentInfos.getUserData());
return Objects.equals(keyId, activeKeyId);
}
private static String elapsedTime(long startTimeMs) {
return (System.currentTimeMillis() - startTimeMs) + " ms";
}
/**
* Wraps a nullable key id (null key id means cleartext).
*/
private static class PendingKeyId {
@Nullable
final String keyId;
PendingKeyId(@Nullable String keyId) {
this.keyId = keyId;
}
}
}