/******************************************************************************* * ADSync4J (https://github.com/zagyi/adsync4j) * * Copyright (c) 2013 Balazs Zagyvai * * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at * http://www.eclipse.org/legal/epl-v10.html * * Contributors: * Balazs Zagyvai *******************************************************************************/ package org.adsync4j.impl; import org.adsync4j.api.ActiveDirectorySyncService; import org.adsync4j.api.InitialFullSyncRequiredException; import org.adsync4j.api.InvocationIdMismatchException; import org.adsync4j.api.LdapClientException; import org.adsync4j.spi.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.annotation.concurrent.NotThreadSafe; import java.util.AbstractList; import java.util.List; import java.util.UUID; import static java.util.Arrays.asList; import static org.adsync4j.impl.ActiveDirectorySyncServiceImpl.ActiveDirectoryAttribute.*; import static org.adsync4j.impl.UUIDUtils.bytesToUUID; /** * Implementation of the main service interface of ADSync4J. * <p/> * In order to start synchronizing content, clients need to create a {@link org.adsync4j.spi.DomainControllerAffiliation} (DCA) * for every domain controller they want to synchronize with, and each DCA requires a dedicated {@link * ActiveDirectorySyncServiceImpl} instance. When creating a service instance, the DCA is not passed directly, * but specified indirectly through a key and a repository of DCAs. This indirection relieves clients of the * responsibility to update the highest committed Update Sequence Number in the DCA and to persist the updated record, * which is crucial for the correct functioning of synchronization operations. * <p/> * This class defines a number of type parameters which are required, so that clients can: * <ul> * <li>provide their own implementation of the {@link org.adsync4j.spi.DomainControllerAffiliation} interface (e.g. a JPA * entity),</li> * <li>provide their own implementation of the {@link org.adsync4j.spi.DCARepository} interface (e.g. a JPA repository),</li> * <li>freely choose an arbitrary key type for the repository,</li> * <li>pick an LDAP SDK to use for implementing the {@link org.adsync4j.spi.LdapClient} interface.</li> * </ul> * <p/> * <b>Important!</b> * The provided {@link DCARepository} implementation must persist DCAs in the same physical database that stores the * synchronized entries. This is necessary in order to ensure the consistency between the DCA and the synchronized data even if * the database fails and has to be restored from a backup. Failing to do so will result in the need for a full * re-synchronization after the database is restored. * <p/> * This class is NOT thread-safe. * * @param <DCA_KEY> Type of the key used in the provided DCA repository. * @param <DCA_IMPL> The implementation class of {@link org.adsync4j.spi.DomainControllerAffiliation}, the instances * of which are stored in the provided DCA repository. * @param <LDAP_ATTRIBUTE> The LDAP attribute type (determined by the {@link org.adsync4j.spi.LdapClient} implementation in use). */ @NotThreadSafe public class ActiveDirectorySyncServiceImpl<DCA_KEY, DCA_IMPL extends DomainControllerAffiliation, LDAP_ATTRIBUTE> implements ActiveDirectorySyncService<LDAP_ATTRIBUTE> { private final static Logger LOG = LoggerFactory.getLogger(ActiveDirectorySyncServiceImpl.class); protected final DCA_KEY _dcaKey; protected final DCARepository<DCA_KEY, DCA_IMPL> _affiliationRepository; protected final LdapClient<LDAP_ATTRIBUTE> _ldapClient; protected final LdapAttributeResolver<LDAP_ATTRIBUTE> _attributeResolver; protected DCA_IMPL _dcAffiliation; /** * Internal interface with two implementations encapsulating the logic of the full and incremental synchronization * operations. Not to be directly used by clients. * * @param <LDAP_ATTRIBUTE> */ protected interface SyncOperation<LDAP_ATTRIBUTE> { void execute(long remoteHighestCommittedUSN, EntryProcessor<LDAP_ATTRIBUTE> entryProcessor); } /** * Internal enum listing a number of attribute names defined in Active Directory that the synchronization service uses. */ protected enum ActiveDirectoryAttribute { /** * Attribute contained in a directory entry that is pointed to by the 'dsServiceName' attribute of the root DSE. It's * value is basically the Domain Controller's identifier. * * @see DomainControllerAffiliation#getInvocationId() */ INVOCATION_ID("invocationID"), /** * An attribute of the root DSE the value of which is the distinguished name of the DS Service entry. That entry * contains (among other attributes) the Domain Controller's Invocation ID. * * @see ActiveDirectoryAttribute#INVOCATION_ID */ DS_SERVICE_NAME("dsServiceName"), /** * Attribute of the root DSE storing the highest Update Sequence Number committed locally by the domain controller. */ HIGHEST_COMMITTED_USN("highestCommittedUSN"), /** * Attribute maintained in every entry in Active Directory recording the Sequence Number of the transaction that most * recently changed the entry. */ USN_CHANGED("uSNChanged"), /** * Attribute stored in every entry in Active Directory recording the Sequence Number of the transaction that created the * entry. */ USN_CREATED("uSNCreated"); private final String _name; private ActiveDirectoryAttribute(String name) { _name = name; } /** * @return The attribute name as used in the Active Directory schema. */ public String key() { return _name; } public String toString() { return _name; } } /** * Constructs a synchronization service instance that is dedicated to work with the specific * {@link DomainControllerAffiliation} record loaded from the provided {@link DCARepository} using the given {@code dcaKey}. * This indirection makes it possible to relieve clients of the responsibility to update the highest committed Update * Sequence Number in the DCA and to persist the updated record, which is crucial for the correct functioning of * synchronization operations. * <p/> * <b>Important!</b> * The provided {@link DCARepository} implementation must persist DCAs in the same physical database that stores the * synchronized entries. This is necessary in order to ensure the consistency between the DCA and the synchronized data * even if the database fails and has to be restored from a backup. Failing to do so will result in the need for a full * re-synchronization after the database is restored. * * @param dcaKey Key of the {@link DomainControllerAffiliation} based on which this service instance * has to perform the synchronization operations. The passed {@code affiliationRepository} must * contain a DCA assigned to this key. * @param affiliationRepository Repository managing {@link DomainControllerAffiliation} entities. * @param ldapClient {@link LdapClient} used by the service to communicate with Active Directory. */ public ActiveDirectorySyncServiceImpl( DCA_KEY dcaKey, DCARepository<DCA_KEY, DCA_IMPL> affiliationRepository, LdapClient<LDAP_ATTRIBUTE> ldapClient) { _dcaKey = dcaKey; _affiliationRepository = affiliationRepository; _ldapClient = ldapClient; _attributeResolver = _ldapClient.getAttributeResolver(); } /** * Performs a full synchronization that retrieves all entries currently found in the synchronization scope in Active * Directory. Entries are delivered one-by-one to the caller by iteratively invoking {@link EntryProcessor#processNew * processNew()} on the provided {@link EntryProcessor}. * <p/> * It ensures that the {@link DomainControllerAffiliation} this service instance uses is {@link DCARepository#save saved} * after updating the current highest committed Update Sequence Number and the Invocation ID it contains. * * @param entryProcessor {@link EntryProcessor} implementation provided by the caller in order to receive * the synchronized entries. * @return The current highest committed Update Sequence Number on the server side that represents the point of time from * which the next incremental synchronization will have to retrieve changes from Active Directory. * @throws LdapClientException in case a problem is encountered during communication with Active Directory. */ @Override public long fullSync(EntryProcessor<LDAP_ATTRIBUTE> entryProcessor) { return doSync(entryProcessor, new SyncOperation<LDAP_ATTRIBUTE>() { @Override public void execute(long remoteHighestCommittedUSN, EntryProcessor<LDAP_ATTRIBUTE> entryProcessor) { String filter = getFilterWithUpperBoundUSN(_dcAffiliation.getSearchFilter(), remoteHighestCommittedUSN); Iterable<LDAP_ATTRIBUTE[]> searchResult = _ldapClient.search( _dcAffiliation.getSyncBaseDN(), filter, _dcAffiliation.getAttributesToSync()); for (LDAP_ATTRIBUTE[] entryAttributes : searchResult) { entryProcessor.processNew(asList(entryAttributes)); } _dcAffiliation.setInvocationId(retrieveInvocationId()); } }); } /** * Performs an incremental synchronization that only retrieves the entries created/changed/deleted after the point of time * represented by the highest committed Update Sequence Number that has been recorded by the last synchronization. Entries * are delivered one-by-one to the caller by iteratively invoking the corresponding methods of the provided * {@link EntryProcessor}. * <p/> * It ensures that the {@link DomainControllerAffiliation} this service instance uses is {@link DCARepository#save saved} * after updating the current highest committed Update Sequence Number and the Invocation ID it contains. * * @param entryProcessor {@link EntryProcessor} implementation provided by the caller in order to receive the synchronized * entries. * @return The current highest committed Update Sequence Number on the server side that represents the point of time from * which the next incremental synchronization will have to retrieve changes from Active Directory. * @throws LdapClientException in case a problem is encountered during communication with Active Directory. */ @Override public long incrementalSync(EntryProcessor<LDAP_ATTRIBUTE> entryProcessor) { return doSync(entryProcessor, new SyncOperation<LDAP_ATTRIBUTE>() { @Override public void execute(long remoteHighestCommittedUSN, EntryProcessor<LDAP_ATTRIBUTE> entryProcessor) { assertIncrementalSyncIsPossible(); queryChangedAndNewEntries(entryProcessor, remoteHighestCommittedUSN); queryDeletedEntries(entryProcessor, remoteHighestCommittedUSN); } }); } /** * Template method that implements a common frame of logic which has to be executed regardless of the specific sync * operation (full or incremental) actually being performed. * <p/> * This template makes sure that the highest committed USN is always retrieved from the server as the first step, * and it's always {@link DomainControllerAffiliation#setHighestCommittedUSN set on the DCA} (which also gets {@link * DCARepository#save persisted}) as the last step. * * @param entryProcessor Call-back object implemented by the client. * @param syncOperation Function object encapsulating the behavior of the specific sync operation to be performed. * @return The highest committed USN retrieved from the server at the beginning of the method. */ private long doSync(EntryProcessor<LDAP_ATTRIBUTE> entryProcessor, SyncOperation<LDAP_ATTRIBUTE> syncOperation) { reloadAffiliation(); long remoteHighestCommittedUSN = retrieveRemoteHighestCommittedUSN(); // delegate to the specific sync operation try { syncOperation.execute(remoteHighestCommittedUSN, entryProcessor); } finally { _ldapClient.closeConnection(); } _dcAffiliation.setHighestCommittedUSN(remoteHighestCommittedUSN); _dcAffiliation = _affiliationRepository.save(_dcAffiliation); LOG.debug("Updated Domain Controller Affiliation record: {}", _dcAffiliation); return remoteHighestCommittedUSN; } void reloadAffiliation() { _dcAffiliation = _affiliationRepository.load(_dcaKey); LOG.debug("Loaded Domain Controller Affiliation record: {}", _dcAffiliation); if (_dcAffiliation == null) { throw new IllegalArgumentException( "The specified Domain Controller Affiliation record is not found in the repository. Requested key was: " + _dcaKey); } } /** * Helper method that checks if properties of the DCA that this service instance holds enable an incremental sync.<br> * Specifically it checks if * <ul> * <li>the Invocation ID stored in the DCA matches the ID retrieved from the server</li> * <li>the DCA contains an Update Sequence Number starting from which the incremental synchronization needs to * retrieve changes</li> * </ul> */ void assertIncrementalSyncIsPossible() { UUID expectedInvocationId = _dcAffiliation.getInvocationId(); Long lastSeenHighestCommittedUSN = _dcAffiliation.getHighestCommittedUSN(); boolean isAnyAffiliationDetailMissing = expectedInvocationId == null || lastSeenHighestCommittedUSN == null; if (isAnyAffiliationDetailMissing) { throw new InitialFullSyncRequiredException(); } UUID actualInvocationId = retrieveInvocationId(); if (!actualInvocationId.equals(expectedInvocationId)) { throw new InvocationIdMismatchException(expectedInvocationId, actualInvocationId); } } /** * Performs an LDAP search that retrieves the list of entries that have been changed or created since the last * synchronization, and iteratively invokes the appropriate method of the provided call-back object with these entries. * <p/> * It includes the {@link ActiveDirectoryAttribute#USN_CREATED USN_CREATED} attribute in the search request, * so that changed and newly created entries can be distinguished. * * @param entryProcessor Call-back object implemented by the client. * @param upperBoundUSN The USN read at the start of synchronization. Marks the point of time until which changed/new * entries should be retrieved. */ protected void queryChangedAndNewEntries(EntryProcessor<LDAP_ATTRIBUTE> entryProcessor, long upperBoundUSN) { String filter = getFilterWithLowerAndUpperBoundUSN(_dcAffiliation.getSearchFilter(), upperBoundUSN); List<String> attributes = new OnePlusListList<>(USN_CREATED.key(), _dcAffiliation.getAttributesToSync()); Iterable<LDAP_ATTRIBUTE[]> searchResult = _ldapClient.search(_dcAffiliation.getSyncBaseDN(), filter, attributes); for (LDAP_ATTRIBUTE[] entry : searchResult) { feedEntryProcessor(entryProcessor, entry); } } /** * A list that is composed of one single element followed by a list of other elements. */ private static class OnePlusListList<T> extends AbstractList<T> { private final T _one; private final List<T> _list; private OnePlusListList(T one, java.util.List<T> list) { _one = one; _list = list; } @Override public T get(int index) { return index == 0 ? _one : _list.get(index - 1); } @Override public int size() { return _list.size() + 1; } } /** * Helper method that dispatches the entry either to the {@link EntryProcessor#processNew processNew()} or to the {@link * EntryProcessor#processChanged processChanged()} call-back method of the provided {@link EntryProcessor}. * <p/> * It is expected that the provided attribute array contains the {@link ActiveDirectoryAttribute#USN_CREATED USN_CREATED} * attribute in its first position based on which this method decides if the entry is new or changed. * <p/> * The dispatched entry will not contain this first attribute, as it's not useful for the client. * * @param entryProcessor Call-back object implemented by the client. * @param entry Attribute array representing the entry. */ private void feedEntryProcessor(EntryProcessor<LDAP_ATTRIBUTE> entryProcessor, LDAP_ATTRIBUTE[] entry) { List<LDAP_ATTRIBUTE> entryWithoutUsnCreatedAttribute = asList(entry).subList(1, entry.length); if (isNewEntry(entry)) { entryProcessor.processNew(entryWithoutUsnCreatedAttribute); } else { entryProcessor.processChanged(entryWithoutUsnCreatedAttribute); } } /** * Helper method that tells if the passed entry is new by comparing its first attribute (that is expected to be the {@link * ActiveDirectoryAttribute#USN_CREATED USN_CREATED} attribute) with the highest committed USN recorded in the DCA. * * @param entry Attribute array representing the entry. * @return True if the entry is new, or false otherwise. */ private boolean isNewEntry(LDAP_ATTRIBUTE[] entry) { LDAP_ATTRIBUTE usnCreatedAttribute = entry[0]; Long usnCreated = _attributeResolver.getAsLong(usnCreatedAttribute); return usnCreated != null && usnCreated > _dcAffiliation.getHighestCommittedUSN(); } /** * Performs an LDAP search that retrieves the ID of every entry that has been deleted since the last synchronization, * and iteratively invokes the {@link EntryProcessor#processDeleted processDeleted()} method of the provided call-back * object passing these IDs. * * @param entryProcessor Call-back object implemented by the client. * @param upperBoundUSN The USN read at the start of synchronization. Marks the point of time until which deleted * entries should be retrieved. */ protected void queryDeletedEntries(EntryProcessor<LDAP_ATTRIBUTE> entryProcessor, long upperBoundUSN) { String filter = getFilterWithLowerAndUpperBoundUSN(_dcAffiliation.getSearchDeletedObjectsFilter(), upperBoundUSN); Iterable<UUID> deletedObjectIds = _ldapClient.searchDeleted(_dcAffiliation.getRootDN(), filter); for (UUID uuid : deletedObjectIds) { entryProcessor.processDeleted(uuid); } } /** * Retrieves the Invocation ID from Active Directory. * * @return The current Invocation ID identifying the affiliated domain controller. */ protected UUID retrieveInvocationId() { LDAP_ATTRIBUTE dsServiceDNAttribute = _ldapClient.getRootDSEAttribute(DS_SERVICE_NAME.key()); String dsServiceDN = _attributeResolver.getAsString(dsServiceDNAttribute); LdapClientException.throwIfNull(dsServiceDN, "Invalid %s attribute encountered: %s", DS_SERVICE_NAME.key(), String.valueOf(dsServiceDNAttribute)); LDAP_ATTRIBUTE invocationIdAttribute = _ldapClient.getEntryAttribute(dsServiceDN, INVOCATION_ID.key()); UUID invocationId = bytesToUUID(_attributeResolver.getAsByteArray(invocationIdAttribute)); LdapClientException.throwIfNull(invocationId, "Invalid Update Sequence Number encountered: %s.", String.valueOf(invocationIdAttribute)); return invocationId; } /** * Retrieves the current highest Update Sequence Number that has been committed up to this point in the database of Active * Directory. * * @return The current highest committed Update Sequence Number. */ protected long retrieveRemoteHighestCommittedUSN() { LDAP_ATTRIBUTE hcusnAttribute = _ldapClient.getRootDSEAttribute(HIGHEST_COMMITTED_USN.key()); Long hcusn = _attributeResolver.getAsLong(hcusnAttribute); LdapClientException.throwIfNull(hcusn, "Invalid Update Sequence Number encountered: %s.", String.valueOf(hcusnAttribute)); // noinspection ConstantConditions return hcusn; } /** * Combines the provided LDAP filter expression with a lower and upper limit on the {@link * ActiveDirectoryAttribute#USN_CHANGED USN_CHANGED} attribute. The highest committed USN stored in the DCA becomes the lower * limit, while the upper limit is the second argument. * <p/> * Called when compiling the filter for an incremental synchronization. * * @param filter The LDAP filter expression to complete. * @param upperBoundUSN Value for the upper limit of the {@link ActiveDirectoryAttribute#USN_CHANGED USN_CHANGED} attribute. * @return The provided LDAP filter combined with lower and upper bounds on the {@link ActiveDirectoryAttribute#USN_CHANGED * USN_CHANGED} attribute. */ protected String getFilterWithLowerAndUpperBoundUSN(String filter, long upperBoundUSN) { long lowerBoundUSN = _dcAffiliation.getHighestCommittedUSN(); String lowerBoundUSNFilter = USN_CHANGED + ">=" + lowerBoundUSN; String upperBoundUSNFilter = USN_CHANGED + "<=" + upperBoundUSN; return and(filter, lowerBoundUSNFilter, upperBoundUSNFilter); } /** * Combines the provided LDAP filter expression with an upper limit on the {@link ActiveDirectoryAttribute#USN_CHANGED * USN_CHANGED} attribute. * <p/> * Called when compiling the filter for a full synchronization. * * @param filter The LDAP filter expression to complete. * @param upperBoundUSN Value for the upper limit of the {@link ActiveDirectoryAttribute#USN_CHANGED USN_CHANGED} attribute. * @return The provided LDAP filter combined with an upper bound on the {@link ActiveDirectoryAttribute#USN_CHANGED * USN_CHANGED} attribute. */ protected String getFilterWithUpperBoundUSN(String filter, long upperBoundUSN) { String usnUpperBoundFilter = USN_CHANGED + "<=" + upperBoundUSN; return and(filter, usnUpperBoundFilter); } /** * Combines the provided LDAP filter expressions into one single expression using the logical AND operator. * * @return A new LDAP filter expression that combines all input filters with the logical AND operator. */ protected static String and(String... filters) { StringBuilder result = new StringBuilder("(&"); for (String filter : filters) { result.append(ensureWrappedInParenthesis(filter)); } return result.append(')').toString(); } /** * Wraps the provided filter expression in parenthesis, unless it's already wrapped. * * @param filter An LDAP filter expression. * @return A {@link StringBuilder} containing the input filter wrapped in parenthesis if it was not wrapped before, * otherwise the original filter expression is returned. */ private static StringBuilder ensureWrappedInParenthesis(String filter) { StringBuilder result = new StringBuilder(filter.length() + 2); if (filter.startsWith("(")) { return result.append(filter); } else { return result .append('(') .append(filter) .append(')'); } } }