/*
* The contents of this file are subject to the terms of the Common Development and
* Distribution License (the License). You may not use this file except in compliance with the
* License.
*
* You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
* specific language governing permission and limitations under the License.
*
* When distributing Covered Software, include this CDDL Header Notice in each file and include
* the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
* Header, with the fields enclosed by brackets [] replaced by your own identifying
* information: "Portions copyright [year] [name of copyright owner]".
*
* Copyright 2026 3A Systems, LLC.
*/
package org.opends.server.backends.pluggable;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.opends.messages.BackendMessages.NOTE_CONFIG_INDEX_CONFIDENTIALITY_REQUIRES_REBUILD;
import static org.opends.server.util.CollectionUtils.newTreeSet;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.forgerock.i18n.LocalizableMessage;
import org.forgerock.opendj.config.server.ConfigChangeResult;
import org.forgerock.opendj.config.server.ConfigException;
import org.forgerock.opendj.ldap.ByteString;
import org.forgerock.opendj.ldap.DN;
import org.forgerock.opendj.ldap.ResultCode;
import org.forgerock.opendj.ldap.schema.AttributeType;
import org.forgerock.opendj.server.config.meta.BackendIndexCfgDefn.IndexType;
import org.forgerock.opendj.server.config.server.BackendIndexCfg;
import org.forgerock.opendj.server.config.server.PluggableBackendCfg;
import org.opends.server.DirectoryServerTestCase;
import org.opends.server.TestCaseUtils;
import org.opends.server.backends.pluggable.AttributeIndex.MatchingRuleIndex;
import org.opends.server.backends.pluggable.spi.Storage;
import org.opends.server.core.AddOperation;
import org.opends.server.core.ServerContext;
import org.opends.server.types.Entry;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.Test;
/**
* Tests that a change of {@code confidentiality-enabled} on a backend index is applied to the
* running backend, rather than only reported as requiring a rebuild which cannot help - see OpenDJ
* issue #992.
*
* The confidentiality of an index is carried by the {@link org.opends.server.crypto.CryptoSuite} the
* indexes of one attribute share, and read from it when an index binds its codec at open time. A
* change which does not put the new setting in force on that suite, and does not bind the codecs
* again, leaves the stored records in the encoding of the previous setting for the life of the
* container.
*/
@SuppressWarnings("javadoc")
@Test(groups = { "precommit", "pluggablebackend" }, sequential = true)
public abstract class IndexConfidentialityChangeTestCase extends DirectoryServerTestCase
{
private static final DN BASE_DN = DN.valueOf("dc=b992,dc=com");
/** Indexed for presence, whose tree a confidentiality change keeps, and for equality, whose it does not. */
private static final String INDEXED_ATTRIBUTE = "sn";
/** The first byte {@code EntryIDSet.EntryIDSetCodecV3} prepends to a record it encrypted. */
private static final byte ENCRYPTED_RECORD_TAG = 0x00;
private static final int ENTRY_LIMIT = 4000;
private ServerContext serverContext;
private AttributeType attributeType;
/**
* Factory method for the configuration of the backend under test, with the settings specific to its
* storage engine stubbed out.
*
* @return the new backend configuration
*/
protected abstract C createBackendCfg();
/**
* Factory method for the storage of the backend under test, which the test reads the stored records
* back from.
*
* @param cfg
* the configuration the backend was configured with
* @param serverContext
* the server context of the running test server
* @return the storage of the backend under test
* @throws ConfigException
* if the configuration is not one the storage can be opened with
*/
protected abstract Storage createStorage(C cfg, ServerContext serverContext) throws ConfigException;
@BeforeClass
public void startServer() throws Exception
{
TestCaseUtils.startServer();
serverContext = TestCaseUtils.getServerContext();
attributeType = serverContext.getSchema().getAttributeType(INDEXED_ATTRIBUTE);
}
/**
* A test which fails before it closes its backend leaves the base DN behind in the server wide
* registry, where it would outlive the test and break the next one to use that DN.
*/
@AfterMethod
public void deregisterLeftoverBaseDN()
{
try
{
serverContext.getBackendConfigManager().deregisterBaseDN(BASE_DN);
}
catch (Exception alreadyGone)
{
// Which is what a test that closed its backend has left behind.
}
}
/**
* The records an index holds are in the encoding of the setting in force when they were written,
* and the codec of the new setting cannot read them back. They are given up here rather than left
* for the searches which run before the rebuild this change asks for.
*/
@Test
public void enablingConfidentialityEmptiesTheIndexItAsksToRebuild() throws Exception
{
final TestBackend backend = openBackend(false);
try
{
addEntry(backend, "user.0");
final AttributeIndex attributeIndex = attributeIndex(backend);
assertThat(recordCount(backend, presenceIndex(attributeIndex))).isEqualTo(1);
final ConfigChangeResult ccr = attributeIndex.applyConfigurationChange(indexCfg(true, ENTRY_LIMIT));
assertThat(ccr.getResultCode()).isEqualTo(ResultCode.SUCCESS);
assertThat(ccr.adminActionRequired()).isTrue();
assertThat(ordinalsOf(ccr)).contains(NOTE_CONFIG_INDEX_CONFIDENTIALITY_REQUIRES_REBUILD.ordinal());
final MatchingRuleIndex presence = presenceIndex(attributeIndex);
assertThat(recordCount(backend, presence)).isEqualTo(0);
assertThat(presence.isTrusted()).isFalse();
// Undefined rather than empty, so that a search of it is not answered with no candidates.
final ByteString key = keyOf(presence, entryOf("user.0"));
final boolean defined = backend.storage.read(txn -> presence.get(txn, key).isDefined());
assertThat(defined).isFalse();
}
finally
{
backend.finalizeBackend();
}
}
/** The setting the operator asked for has to reach what the index writes from then on. */
@Test
public void enablingConfidentialityEncryptsWhatTheIndexWritesNext() throws Exception
{
final TestBackend backend = openBackend(false);
try
{
final AttributeIndex attributeIndex = attributeIndex(backend);
addEntry(backend, "user.0");
assertThat(rawRecord(backend, presenceIndex(attributeIndex), "user.0").byteAt(0))
.isNotEqualTo(ENCRYPTED_RECORD_TAG);
attributeIndex.applyConfigurationChange(indexCfg(true, ENTRY_LIMIT));
trust(backend, attributeIndex);
addEntry(backend, "user.1");
final MatchingRuleIndex presence = presenceIndex(attributeIndex);
assertThat(rawRecord(backend, presence, "user.1").byteAt(0)).isEqualTo(ENCRYPTED_RECORD_TAG);
assertThat(idsOf(backend, presence, "user.1")).hasSize(1);
}
finally
{
backend.finalizeBackend();
}
}
/**
* Enabling confidentiality of an equality index replaces its tree with one whose keys are hashed,
* which the change creates and opens itself. That one has to be opened with the new setting in
* force, or its keys are protected while its records are not.
*/
@Test
public void enablingConfidentialityEncryptsTheKeyHashedIndexItCreates() throws Exception
{
final TestBackend backend = openBackend(false);
try
{
final AttributeIndex attributeIndex = attributeIndex(backend);
addEntry(backend, "user.0");
assertThat(keyHashedIndex(attributeIndex)).isNull();
attributeIndex.applyConfigurationChange(indexCfg(true, ENTRY_LIMIT));
trust(backend, attributeIndex);
addEntry(backend, "user.1");
final MatchingRuleIndex hashed = keyHashedIndex(attributeIndex);
assertThat(hashed).isNotNull();
assertThat(rawRecord(backend, hashed, "user.1").byteAt(0)).isEqualTo(ENCRYPTED_RECORD_TAG);
assertThat(idsOf(backend, hashed, "user.1")).hasSize(1);
}
finally
{
backend.finalizeBackend();
}
}
/** And the same the other way around: giving the setting up has to stop the encryption. */
@Test
public void disablingConfidentialityStopsEncryptingWhatTheIndexWritesNext() throws Exception
{
final TestBackend backend = openBackend(true);
try
{
final AttributeIndex attributeIndex = attributeIndex(backend);
addEntry(backend, "user.0");
assertThat(rawRecord(backend, presenceIndex(attributeIndex), "user.0").byteAt(0))
.isEqualTo(ENCRYPTED_RECORD_TAG);
attributeIndex.applyConfigurationChange(indexCfg(false, ENTRY_LIMIT));
// The tree given up carries the record written under the setting just given up, which the
// codec of the new one does not read back as what it is - so it is given up here regardless of
// which way the setting goes, not only where enabling replaces it with a key hashed twin.
assertThat(recordCount(backend, presenceIndex(attributeIndex))).as("the encrypted tree, given up").isEqualTo(0);
trust(backend, attributeIndex);
addEntry(backend, "user.1");
final MatchingRuleIndex presence = presenceIndex(attributeIndex);
assertThat(rawRecord(backend, presence, "user.1").byteAt(0)).isNotEqualTo(ENCRYPTED_RECORD_TAG);
assertThat(idsOf(backend, presence, "user.1")).hasSize(1);
}
finally
{
backend.finalizeBackend();
}
}
/**
* Once the change is applied, the comparison it is reported by converges: an unrelated change of
* the same index must not untrust it again, and must not repeat the rebuild message.
*/
@Test
public void aLaterUnrelatedChangeLeavesTheIndexTrusted() throws Exception
{
final TestBackend backend = openBackend(false);
try
{
final AttributeIndex attributeIndex = attributeIndex(backend);
addEntry(backend, "user.0");
attributeIndex.applyConfigurationChange(indexCfg(true, ENTRY_LIMIT));
trust(backend, attributeIndex);
// A smaller index entry limit does not invalidate what the index holds, so this change has no
// rebuild of its own to ask for.
final ConfigChangeResult ccr = attributeIndex.applyConfigurationChange(indexCfg(true, ENTRY_LIMIT - 1));
assertThat(ccr.getResultCode()).isEqualTo(ResultCode.SUCCESS);
assertThat(ccr.getMessages()).isEmpty();
assertThat(ccr.adminActionRequired()).isFalse();
for (MatchingRuleIndex index : attributeIndex.getNameToIndexes().values())
{
assertThat(index.isTrusted()).as(index.getName().toString()).isTrue();
}
}
finally
{
backend.finalizeBackend();
}
}
private TestBackend openBackend(boolean indexConfidentiality) throws Exception
{
final C cfg = backendCfg(indexConfidentiality);
final TestBackend backend = new TestBackend();
backend.setBackendID(cfg.getBackendId());
backend.configureBackend(cfg, serverContext);
// Start from a pristine on-disk state, so that a previous run cannot mask the defect.
backend.storage.removeStorageFiles();
try
{
backend.openBackend();
backend.addEntry(TestCaseUtils.makeEntry(
"dn: " + BASE_DN,
"objectClass: top",
"objectClass: domain",
"dc: b992"), mock(AddOperation.class));
}
catch (Exception e)
{
// openBackend() registers the base DN and the monitor before it returns, so a failure after
// that would leave both behind and break every following test rather than only this one.
try
{
backend.finalizeBackend();
}
catch (Exception cleanupFailure)
{
e.addSuppressed(cleanupFailure);
}
throw e;
}
return backend;
}
private Entry addEntry(TestBackend backend, String uid) throws Exception
{
final Entry entry = entryOf(uid);
backend.addEntry(entry, mock(AddOperation.class));
return entry;
}
/** Trusts every index of the attribute, which is what the rebuild the change asks for leaves behind. */
private void trust(TestBackend backend, final AttributeIndex attributeIndex) throws Exception
{
backend.storage.write(txn -> {
for (MatchingRuleIndex index : attributeIndex.getNameToIndexes().values())
{
index.setTrusted(txn, true);
}
});
}
private AttributeIndex attributeIndex(TestBackend backend)
{
return backend.getRootContainer().getEntryContainer(BASE_DN).getAttributeIndex(attributeType);
}
private static MatchingRuleIndex presenceIndex(AttributeIndex attributeIndex)
{
return attributeIndex.getNameToIndexes().get(IndexType.PRESENCE.toString());
}
private static MatchingRuleIndex keyHashedIndex(AttributeIndex attributeIndex)
{
for (Map.Entry index : attributeIndex.getNameToIndexes().entrySet())
{
if (index.getKey().endsWith(AttributeIndex.PROTECTED_INDEX_ID))
{
return index.getValue();
}
}
return null;
}
private static ByteString keyOf(MatchingRuleIndex index, Entry entry)
{
return index.indexEntry(entry).iterator().next();
}
/** The record as it is stored, which is what says whether it was encrypted. */
private ByteString rawRecord(TestBackend backend, final MatchingRuleIndex index, String uid) throws Exception
{
final ByteString key = keyOf(index, entryOf(uid));
final ByteString record = backend.storage.read(txn -> txn.read(index.getName(), key));
assertThat(record).as("the record of " + index.getName() + " at key " + key).isNotNull();
return record;
}
private List idsOf(TestBackend backend, final MatchingRuleIndex index, String uid) throws Exception
{
final ByteString key = keyOf(index, entryOf(uid));
final EntryIDSet idSet = backend.storage.read(txn -> index.get(txn, key));
assertThat(idSet.isDefined()).as("the entry IDs of " + index.getName() + " at key " + key).isTrue();
final List ids = new ArrayList<>();
for (EntryID id : idSet)
{
ids.add(id.longValue());
}
return ids;
}
/** The entry as it was added, which the indexers generate the keys of a record from. */
private Entry entryOf(String uid) throws Exception
{
return TestCaseUtils.makeEntry(
"dn: uid=" + uid + "," + BASE_DN,
"objectClass: top",
"objectClass: person",
"objectClass: organizationalPerson",
"objectClass: inetOrgPerson",
"uid: " + uid,
"cn: " + uid,
"sn: " + uid);
}
private long recordCount(TestBackend backend, final MatchingRuleIndex index) throws Exception
{
return backend.storage.read(txn -> index.getRecordCount(txn));
}
private static Set ordinalsOf(ConfigChangeResult ccr)
{
final Set ordinals = new HashSet<>();
for (LocalizableMessage message : ccr.getMessages())
{
ordinals.add(message.ordinal());
}
return ordinals;
}
private C backendCfg(boolean indexConfidentiality) throws ConfigException
{
final C cfg = createBackendCfg();
// Read outside the when() below, which calling a mock inside would leave unfinished.
final String backendId = cfg.getBackendId();
when(cfg.dn()).thenReturn(DN.valueOf("ds-cfg-backend-id=" + backendId + ",cn=Backends,cn=config"));
when(cfg.getBaseDN()).thenReturn(newTreeSet(BASE_DN));
when(cfg.listBackendIndexes()).thenReturn(new String[] { INDEXED_ATTRIBUTE });
when(cfg.listBackendVLVIndexes()).thenReturn(new String[0]);
// An index can only be confidential in a backend which is, and the cipher of the backend is what
// the crypto suite of an index takes its parameters from.
when(cfg.isConfidentialityEnabled()).thenReturn(true);
when(cfg.getCipherTransformation()).thenReturn("AES/CBC/PKCS5Padding");
when(cfg.getCipherKeyLength()).thenReturn(128);
// Stubbed outside the when() below, which stubbing another mock inside would leave unfinished.
final BackendIndexCfg indexCfg = indexCfg(indexConfidentiality, ENTRY_LIMIT);
when(cfg.getBackendIndex(INDEXED_ATTRIBUTE)).thenReturn(indexCfg);
return cfg;
}
private BackendIndexCfg indexCfg(boolean confidentiality, int entryLimit)
{
final BackendIndexCfg cfg = mock(BackendIndexCfg.class);
when(cfg.getIndexType()).thenReturn(newTreeSet(IndexType.PRESENCE, IndexType.EQUALITY));
when(cfg.getAttribute()).thenReturn(attributeType);
when(cfg.getIndexEntryLimit()).thenReturn(entryLimit);
when(cfg.getSubstringLength()).thenReturn(6);
when(cfg.isConfidentialityEnabled()).thenReturn(confidentiality);
return cfg;
}
/** A backend whose storage the test reads the stored records back from. */
private final class TestBackend extends BackendImpl
{
private Storage storage;
@Override
protected Storage configureStorage(C cfg, ServerContext serverContext) throws ConfigException
{
storage = createStorage(cfg, serverContext);
return storage;
}
}
}