diff --git a/config/alfresco/public-services-security-context.xml b/config/alfresco/public-services-security-context.xml index bc9e01881a..1a87166940 100644 --- a/config/alfresco/public-services-security-context.xml +++ b/config/alfresco/public-services-security-context.xml @@ -702,6 +702,7 @@ org.alfresco.service.cmr.security.AuthorityService.hasAdminAuthority=ACL_ALLOW org.alfresco.service.cmr.security.AuthorityService.isAdminAuthority=ACL_ALLOW + org.alfresco.service.cmr.security.AuthorityService.isGuestAuthority=ACL_ALLOW org.alfresco.service.cmr.security.AuthorityService.getAuthorities=ACL_ALLOW org.alfresco.service.cmr.security.AuthorityService.getAuthoritiesForUser=ACL_METHOD.ROLE_ADMINISTRATOR org.alfresco.service.cmr.security.AuthorityService.getAllAuthorities=ACL_ALLOW diff --git a/config/alfresco/subsystems/Authentication/common-ldap-context.xml b/config/alfresco/subsystems/Authentication/common-ldap-context.xml index a38df65dfa..7b5292af23 100644 --- a/config/alfresco/subsystems/Authentication/common-ldap-context.xml +++ b/config/alfresco/subsystems/Authentication/common-ldap-context.xml @@ -35,17 +35,11 @@ - ${ldap.authentication.userNameFormat} + + + @@ -340,6 +334,10 @@ + + + ${ldap.synchronization.enableProgressEstimation} + diff --git a/config/alfresco/subsystems/Authentication/ldap-ad/ldap-ad-authentication.properties b/config/alfresco/subsystems/Authentication/ldap-ad/ldap-ad-authentication.properties index 8f9f130b94..4adda16f2e 100644 --- a/config/alfresco/subsystems/Authentication/ldap-ad/ldap-ad-authentication.properties +++ b/config/alfresco/subsystems/Authentication/ldap-ad/ldap-ad-authentication.properties @@ -104,3 +104,6 @@ ldap.synchronization.personType=user # The attribute in LDAP on group objects that defines the DN for its members ldap.synchronization.groupMemberAttributeName=member + +# If true progress estimation is enabled. When enabled, the user query has to be run twice in order to count entries. +ldap.synchronization.enableProgressEstimation=true \ No newline at end of file diff --git a/config/alfresco/subsystems/Authentication/ldap/ldap-authentication.properties b/config/alfresco/subsystems/Authentication/ldap/ldap-authentication.properties index 6806e98e8c..3df9129a8e 100644 --- a/config/alfresco/subsystems/Authentication/ldap/ldap-authentication.properties +++ b/config/alfresco/subsystems/Authentication/ldap/ldap-authentication.properties @@ -7,14 +7,17 @@ ldap.authentication.active=true # This properties file brings together the common options for LDAP authentication rather than editing the bean definitions # ldap.authentication.allowGuestLogin=true -# How to map the user id entered by the user to taht passed through to LDAP +# How to map the user id entered by the user to that passed through to LDAP # - simple # - this must be a DN and would be something like # uid=%s,ou=People,dc=company,dc=com # - digest # - usually pass through what is entered -# %s -ldap.authentication.userNameFormat=uid\=%s,ou\=People,dc\=company,dc\=com +# %s +# If not set, an LDAP query involving ldap.synchronization.personQuery and ldap.synchronization.userIdAttributeName will +# be performed to resolve the DN dynamically. This allows directories to be structured and doesn't require the user ID to +# appear in the DN. +ldap.authentication.userNameFormat= # The LDAP context factory to use ldap.authentication.java.naming.factory.initial=com.sun.jndi.ldap.LdapCtxFactory @@ -107,3 +110,6 @@ ldap.synchronization.personType=inetOrgPerson # The attribute in LDAP on group objects that defines the DN for its members ldap.synchronization.groupMemberAttributeName=member + +# If true progress estimation is enabled. When enabled, the user query has to be run twice in order to count entries. +ldap.synchronization.enableProgressEstimation=true \ No newline at end of file diff --git a/config/alfresco/subsystems/Synchronization/default/default-synchronization-context.xml b/config/alfresco/subsystems/Synchronization/default/default-synchronization-context.xml index 22bdaba0a0..8f3b99d6ab 100644 --- a/config/alfresco/subsystems/Synchronization/default/default-synchronization-context.xml +++ b/config/alfresco/subsystems/Synchronization/default/default-synchronization-context.xml @@ -65,6 +65,12 @@ userRegistry + + ${synchronization.loggingInterval} + + + ${synchronization.workerThreads} + diff --git a/config/alfresco/subsystems/Synchronization/default/default-synchronization.properties b/config/alfresco/subsystems/Synchronization/default/default-synchronization.properties index 81ae75786c..d3e535bfab 100644 --- a/config/alfresco/subsystems/Synchronization/default/default-synchronization.properties +++ b/config/alfresco/subsystems/Synchronization/default/default-synchronization.properties @@ -19,4 +19,10 @@ synchronization.syncWhenMissingPeopleLogIn=true synchronization.syncOnStartup=true # Should we auto create a missing person on log in? -synchronization.autoCreatePeopleOnLogin=true \ No newline at end of file +synchronization.autoCreatePeopleOnLogin=true + +# The number of entries to process before logging progress +synchronization.loggingInterval=100 + +# The number of threads to use when doing a batch (scheduled or startup) sync +synchronization.workerThreads=2 \ No newline at end of file diff --git a/source/java/org/alfresco/repo/jscript/People.java b/source/java/org/alfresco/repo/jscript/People.java index 0d2b37a138..b5f50b8471 100644 --- a/source/java/org/alfresco/repo/jscript/People.java +++ b/source/java/org/alfresco/repo/jscript/People.java @@ -712,6 +712,19 @@ public final class People extends BaseScopableProcessorExtension return this.authorityService.isAdminAuthority((String)person.getProperties().get(ContentModel.PROP_USERNAME)); } + /** + * Return true if the specified user is an guest authority. + * + * @param person to test + * + * @return true if an admin, false otherwise + */ + public boolean isGuest(ScriptNode person) + { + ParameterCheck.mandatory("Person", person); + return this.authorityService.isGuestAuthority((String) person.getProperties().get(ContentModel.PROP_USERNAME)); + } + /** * Get Contained Authorities * diff --git a/source/java/org/alfresco/repo/security/authentication/AbstractAuthenticationComponent.java b/source/java/org/alfresco/repo/security/authentication/AbstractAuthenticationComponent.java index e7ab7a29a7..ac995f3fc2 100644 --- a/source/java/org/alfresco/repo/security/authentication/AbstractAuthenticationComponent.java +++ b/source/java/org/alfresco/repo/security/authentication/AbstractAuthenticationComponent.java @@ -191,19 +191,12 @@ public abstract class AbstractAuthenticationComponent implements AuthenticationC throw new UnsupportedOperationException(); } - public Authentication setCurrentUser(String userName, UserNameValidationMode validationMode) + public Authentication setCurrentUser(final String userName) throws AuthenticationException { - switch (validationMode) - { - case NONE: - return setCurrentUserImpl(userName); - case CHECK_AND_FIX: - default: - return setCurrentUser(userName); - } + return setCurrentUser(userName, UserNameValidationMode.CHECK_AND_FIX); } - public Authentication setCurrentUser(final String userName) throws AuthenticationException + public Authentication setCurrentUser(String userName, UserNameValidationMode validationMode) { if (isSystemUserName(userName)) { @@ -211,28 +204,32 @@ public abstract class AbstractAuthenticationComponent implements AuthenticationC } else { - SetCurrentUserCallback callback = new SetCurrentUserCallback(userName); - Authentication auth; - // If the repository is read only, we have to settle for a read only transaction. Auto user creation will - // not be possible. + CurrentUserCallback callback = validationMode == UserNameValidationMode.CHECK_AND_FIX ? new FixCurrentUserCallback( + userName) + : new CheckCurrentUserCallback(userName); + Authentication authentication; + // If the repository is read only, we have to settle for a read only transaction. Auto user creation + // will not be possible. if (transactionService.isReadOnly()) { - auth = transactionService.getRetryingTransactionHelper().doInTransaction(callback, true, false); + authentication = transactionService.getRetryingTransactionHelper().doInTransaction(callback, true, + false); } // Otherwise, we want a writeable transaction, so if the current transaction is read only we set the // requiresNew flag to true else { - auth = transactionService.getRetryingTransactionHelper().doInTransaction(callback, false, + authentication = transactionService.getRetryingTransactionHelper().doInTransaction(callback, false, AlfrescoTransactionSupport.getTransactionReadState() == TxnReadState.TXN_READ_ONLY); } - if ((auth == null) || (callback.ae != null)) + if ((authentication == null) || (callback.ae != null)) { throw callback.ae; } - return auth; + return authentication; } } + /** * Explicitly set the current user to be authenticated. @@ -451,37 +448,87 @@ public abstract class AbstractAuthenticationComponent implements AuthenticationC authenticationContext.clearCurrentSecurityContext(); } - class SetCurrentUserCallback implements RetryingTransactionHelper.RetryingTransactionCallback + abstract class CurrentUserCallback implements RetryingTransactionHelper.RetryingTransactionCallback { AuthenticationException ae = null; String userName; - SetCurrentUserCallback(String userName) + CurrentUserCallback(String userName) { this.userName = userName; } + } + + class CheckCurrentUserCallback extends CurrentUserCallback + { + + CheckCurrentUserCallback(String userName) + { + super(userName); + } public Authentication execute() throws Throwable { try { - String name = AuthenticationUtil.runAs(new RunAsWork() + // We must set full authentication before calling runAs in order to retain tickets + Authentication authentication = setCurrentUserImpl(userName); + AuthenticationUtil.runAs(new RunAsWork() + { + public Object doWork() throws Exception + { + if (!personService.personExists(userName) + || !nodeService.getProperty(personService.getPerson(userName), + ContentModel.PROP_USERNAME).equals(userName)) + { + if (logger.isDebugEnabled()) + { + logger.debug("User \"" + userName + + "\" does not exist in Alfresco. Failing validation."); + } + throw new AuthenticationException("User \"" + userName + "\" does not exist in Alfresco"); + } + return null; + } + }, getSystemUserName(getUserDomain(userName))); + return authentication; + } + catch (AuthenticationException ae) + { + this.ae = ae; + return null; + } + } + } + + class FixCurrentUserCallback extends CurrentUserCallback + { + FixCurrentUserCallback(String userName) + { + super(userName); + } + + public Authentication execute() throws Throwable + { + try + { + return setCurrentUserImpl(AuthenticationUtil.runAs(new RunAsWork() { public String doWork() throws Exception { if (!personService.personExists(userName)) { - if (logger.isDebugEnabled()) + if (logger.isDebugEnabled()) { - logger.debug("User \"" + userName - + "\" does not exist in Alfresco. Attempting to import / create the user."); + logger.debug("User \"" + userName + + "\" does not exist in Alfresco. Attempting to import / create the user."); } - if (!userRegistrySynchronizer.createMissingPerson(userName)) + if (!userRegistrySynchronizer.createMissingPerson(userName)) { if (logger.isDebugEnabled()) { - logger.debug("Failed to import / create user \"" + userName + '"'); + logger.debug("Failed to import / create user \"" + userName + '"'); } throw new AuthenticationException("User \"" + userName + "\" does not exist in Alfresco"); @@ -492,9 +539,7 @@ public abstract class AbstractAuthenticationComponent implements AuthenticationC // checks return (String) nodeService.getProperty(userNode, ContentModel.PROP_USERNAME); } - }, getSystemUserName(getUserDomain(userName))); - - return setCurrentUserImpl(name); + }, getSystemUserName(getUserDomain(userName)))); } catch (AuthenticationException ae) { diff --git a/source/java/org/alfresco/repo/security/authentication/AuthenticationComponent.java b/source/java/org/alfresco/repo/security/authentication/AuthenticationComponent.java index 1b9e0e4f48..7337629c7f 100644 --- a/source/java/org/alfresco/repo/security/authentication/AuthenticationComponent.java +++ b/source/java/org/alfresco/repo/security/authentication/AuthenticationComponent.java @@ -32,7 +32,7 @@ public interface AuthenticationComponent extends AuthenticationContext { public enum UserNameValidationMode { - NONE, CHECK_AND_FIX; + CHECK, CHECK_AND_FIX; } /** diff --git a/source/java/org/alfresco/repo/security/authentication/AuthenticationServiceImpl.java b/source/java/org/alfresco/repo/security/authentication/AuthenticationServiceImpl.java index 3edce9c7c4..da4c8cfcec 100644 --- a/source/java/org/alfresco/repo/security/authentication/AuthenticationServiceImpl.java +++ b/source/java/org/alfresco/repo/security/authentication/AuthenticationServiceImpl.java @@ -174,18 +174,20 @@ public class AuthenticationServiceImpl extends AbstractAuthenticationService imp public void validate(String ticket) throws AuthenticationException { + String currentUser = null; try { - // clear context - to avoid MT concurrency issue (causing domain mismatch) - see also 'authenticate' above - clearCurrentSecurityContext(); - authenticationComponent.setCurrentUser(ticketComponent.validateTicket(ticket), UserNameValidationMode.NONE); + // clear context - to avoid MT concurrency issue (causing domain mismatch) - see also 'authenticate' above + clearCurrentSecurityContext(); + currentUser = ticketComponent.validateTicket(ticket); + authenticationComponent.setCurrentUser(currentUser, UserNameValidationMode.CHECK); } - catch(AuthenticationException ae) + catch (AuthenticationException ae) { clearCurrentSecurityContext(); throw ae; - } + } } public String getCurrentTicket() diff --git a/source/java/org/alfresco/repo/security/authentication/ldap/LDAPAuthenticationComponentImpl.java b/source/java/org/alfresco/repo/security/authentication/ldap/LDAPAuthenticationComponentImpl.java index 0b052c07ef..ff849d29f0 100644 --- a/source/java/org/alfresco/repo/security/authentication/ldap/LDAPAuthenticationComponentImpl.java +++ b/source/java/org/alfresco/repo/security/authentication/ldap/LDAPAuthenticationComponentImpl.java @@ -30,13 +30,17 @@ import javax.naming.directory.InitialDirContext; import org.alfresco.repo.management.subsystems.ActivateableBean; import org.alfresco.repo.security.authentication.AbstractAuthenticationComponent; import org.alfresco.repo.security.authentication.AuthenticationException; +import org.alfresco.repo.security.sync.ldap.LDAPNameResolver; +import org.springframework.beans.factory.InitializingBean; /** - * Currently expects the cn name of the user which is in a fixed location. + * Authenticates a user by LDAP. To convert the user name to an LDAP DN, it uses the fixed format in + * userNameFormat if set, or calls the {@link LDAPNameResolver} otherwise. * * @author Andy Hind */ -public class LDAPAuthenticationComponentImpl extends AbstractAuthenticationComponent implements ActivateableBean +public class LDAPAuthenticationComponentImpl extends AbstractAuthenticationComponent implements InitializingBean, + ActivateableBean { private boolean escapeCommasInBind = false; @@ -45,6 +49,8 @@ public class LDAPAuthenticationComponentImpl extends AbstractAuthenticationCompo private boolean active = true; private String userNameFormat; + + private LDAPNameResolver ldapNameResolver; private LDAPInitialDirContextFactory ldapInitialContextFactory; @@ -60,9 +66,14 @@ public class LDAPAuthenticationComponentImpl extends AbstractAuthenticationCompo public void setUserNameFormat(String userNameFormat) { - this.userNameFormat = userNameFormat; + this.userNameFormat = userNameFormat == null || userNameFormat.length() == 0 ? null : userNameFormat; } - + + public void setLdapNameResolver(LDAPNameResolver ldapNameResolver) + { + this.ldapNameResolver = ldapNameResolver; + } + public void setEscapeCommasInBind(boolean escapeCommasInBind) { this.escapeCommasInBind = escapeCommasInBind; @@ -85,6 +96,18 @@ public class LDAPAuthenticationComponentImpl extends AbstractAuthenticationCompo public boolean isActive() { return this.active; + } + + /* + * (non-Javadoc) + * @see org.springframework.beans.factory.InitializingBean#afterPropertiesSet() + */ + public void afterPropertiesSet() throws Exception + { + if (this.ldapNameResolver == null && this.userNameFormat == null) + { + throw new IllegalStateException("At least one of ldapNameResolver and userNameFormat must be set"); + } } /** @@ -92,10 +115,17 @@ public class LDAPAuthenticationComponentImpl extends AbstractAuthenticationCompo */ protected void authenticateImpl(String userName, char[] password) throws AuthenticationException { + // If we aren't using a fixed name format, do a search to resolve the user DN + String userDN = userNameFormat == null ? ldapNameResolver.resolveDistinguishedName(userName) : String.format( + userNameFormat, new Object[] + { + escapeUserName(userName, escapeCommasInBind) + }); + InitialDirContext ctx = null; try { - ctx = ldapInitialContextFactory.getInitialDirContext(String.format(userNameFormat, new Object[] { escapeUserName(userName, escapeCommasInBind) }), new String(password)); + ctx = ldapInitialContextFactory.getInitialDirContext(userDN, new String(password)); // Authentication has been successful. // Set the current user, they are now authenticated. diff --git a/source/java/org/alfresco/repo/security/authentication/ldap/LDAPInitialDirContextFactoryImpl.java b/source/java/org/alfresco/repo/security/authentication/ldap/LDAPInitialDirContextFactoryImpl.java index 67a6482e77..f907edc226 100644 --- a/source/java/org/alfresco/repo/security/authentication/ldap/LDAPInitialDirContextFactoryImpl.java +++ b/source/java/org/alfresco/repo/security/authentication/ldap/LDAPInitialDirContextFactoryImpl.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2005-2007 Alfresco Software Limited. + * Copyright (C) 2005-2009 Alfresco Software Limited. * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License @@ -87,6 +87,7 @@ public class LDAPInitialDirContextFactoryImpl implements LDAPInitialDirContextFa Hashtable env = new Hashtable(initialDirContextEnvironment.size()); env.putAll(initialDirContextEnvironment); env.put("javax.security.auth.useSubjectCredsOnly", "false"); + env.put("com.sun.jndi.ldap.connect.pool", "true"); // Pool the default connection return buildInitialDirContext(env, pageSize); } diff --git a/source/java/org/alfresco/repo/security/person/PersonServiceImpl.java b/source/java/org/alfresco/repo/security/person/PersonServiceImpl.java index ffc1acc5c6..3e63a1389a 100644 --- a/source/java/org/alfresco/repo/security/person/PersonServiceImpl.java +++ b/source/java/org/alfresco/repo/security/person/PersonServiceImpl.java @@ -670,6 +670,16 @@ public class PersonServiceImpl extends TransactionListenerAdapter implements Per { // Ignore this - externally authenticated user } + + // Invalidate all that user's tickets + try + { + authenticationService.invalidateUserSession(userName); + } + catch (AuthenticationException e) + { + // Ignore this + } // remove user from any containing authorities Set containerAuthorities = authorityService.getContainingAuthorities(null, userName, true); diff --git a/source/java/org/alfresco/repo/security/sync/BatchMonitor.java b/source/java/org/alfresco/repo/security/sync/BatchMonitor.java new file mode 100644 index 0000000000..45c648c965 --- /dev/null +++ b/source/java/org/alfresco/repo/security/sync/BatchMonitor.java @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2005-2009 Alfresco Software Limited. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + + * As a special exception to the terms and conditions of version 2.0 of + * the GPL, you may redistribute this Program in connection with Free/Libre + * and Open Source Software ("FLOSS") applications as described in Alfresco's + * FLOSS exception. You should have received a copy of the text describing + * the FLOSS exception, and it is also available here: + * http://www.alfresco.com/legal/licensing" + */ +package org.alfresco.repo.security.sync; + +import java.util.Date; + +/** + * An interface that allows the monitoring of metrics relating to a potentially long-running batch process. + * + * @author dward + */ +public interface BatchMonitor +{ + /** + * Gets the process name. + * + * @return the process name + */ + public String getProcessName(); + + /** + * Gets the start time. + * + * @return the start time + */ + public Date getStartTime(); + + /** + * Gets the total number of results. + * + * @return the total number of results + */ + public int getTotalResults(); + + /** + * Gets the ID of the entry being processed + * + * @return the current entry id + */ + public String getCurrentEntryId(); + + /** + * Gets the number of successfully processed entries. + * + * @return the successfully processed entries + */ + public int getSuccessfullyProcessedEntries(); + + /** + * Gets the progress expressed as a percentage. + * + * @return the progress expressed as a percentage + */ + public String getPercentComplete(); + + /** + * Gets the total number of errors. + * + * @return the total number of errors + */ + public int getTotalErrors(); + + /** + * Gets the stack trace of the last error. + * + * @return the stack trace of the last error + */ + public String getLastError(); + + /** + * Gets the entry id that caused the last error. + * + * @return the last error entry id + */ + public String getLastErrorEntryId(); + + /** + * Gets the end time. + * + * @return the end time + */ + public Date getEndTime(); +} diff --git a/source/java/org/alfresco/repo/security/sync/BatchMonitorEvent.java b/source/java/org/alfresco/repo/security/sync/BatchMonitorEvent.java new file mode 100644 index 0000000000..56ae1844ec --- /dev/null +++ b/source/java/org/alfresco/repo/security/sync/BatchMonitorEvent.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2005-2009 Alfresco Software Limited. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + + * As a special exception to the terms and conditions of version 2.0 of + * the GPL, you may redistribute this Program in connection with Free/Libre + * and Open Source Software ("FLOSS") applications as described in Alfresco's + * FLOSS exception. You should have received a copy of the text describing + * the FLOSS exception, and it is also available here: + * http://www.alfresco.com/legal/licensing" + */ +package org.alfresco.repo.security.sync; + +import org.springframework.context.ApplicationEvent; + +/** + * An event alerting listeners to the existence of a new {@link BatchMonitor}. + * + * @author dward + */ +public class BatchMonitorEvent extends ApplicationEvent +{ + + private static final long serialVersionUID = -5787104103292355106L; + + /** + * The Constructor. + * + * @param source + * the source of the event + */ + public BatchMonitorEvent(BatchMonitor source) + { + super(source); + } + + /** + * Gets the source batch monitor. + * + * @return the batch monitor + */ + public BatchMonitor getBatchMonitor() + { + return (BatchMonitor) getSource(); + } + +} diff --git a/source/java/org/alfresco/repo/security/sync/BatchProcessor.java b/source/java/org/alfresco/repo/security/sync/BatchProcessor.java new file mode 100644 index 0000000000..0b0ce21b1d --- /dev/null +++ b/source/java/org/alfresco/repo/security/sync/BatchProcessor.java @@ -0,0 +1,608 @@ +/* + * Copyright (C) 2005-2009 Alfresco Software Limited. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + + * As a special exception to the terms and conditions of version 2.0 of + * the GPL, you may redistribute this Program in connection with Free/Libre + * and Open Source Software ("FLOSS") applications as described in Alfresco's + * FLOSS exception. You should have received a copy of the text describing + * the FLOSS exception, and it is also available here: + * http://www.alfresco.com/legal/licensing" + */ +package org.alfresco.repo.security.sync; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.io.Writer; +import java.text.NumberFormat; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Date; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +import org.alfresco.error.AlfrescoRuntimeException; +import org.alfresco.repo.transaction.RetryingTransactionHelper; +import org.alfresco.repo.transaction.RetryingTransactionHelper.RetryingTransactionCallback; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.context.ApplicationEventPublisher; + +/** + * A BatchProcessor manages the running and monitoring of a potentially long-running transactional batch + * process. It iterates over a collection, and queues jobs that fire a worker on a batch of members. The queued jobs + * handle progress / error reporting, transaction delineation and retrying. They are processed in parallel by a pool of + * threads of a configurable size. The job processing is designed to be fault tolerant and will continue in the event of + * errors. When the batch is complete a summary of the number of errors and the last error stack trace will be logged at + * ERROR level. Each individual error is logged at WARN level and progress information is logged at INFO level. Through + * the {@link BatchMonitor} interface, it also supports the real-time monitoring of batch metrics (e.g. over JMX in the + * Enterprise Edition). + * + * @author dward + */ +public class BatchProcessor implements BatchMonitor +{ + /** Let's share the logger of ChainingUserRegistrySynchronizer. */ + private static final Log logger = LogFactory.getLog(ChainingUserRegistrySynchronizer.class); + + /** The retrying transaction helper. */ + private RetryingTransactionHelper retryingTransactionHelper; + + /** The collection. */ + private Collection collection; + + /** The process name. */ + private String processName; + + /** The number of entries to process before reporting progress. */ + private int loggingInterval; + + /** The number of worker threads. */ + private int workerThreads; + + /** The number of entries we process at a time in a transaction *. */ + private final int batchSize; + + /** The current entry id. */ + private String currentEntryId; + + /** The last error. */ + private Throwable lastError; + + /** The last error entry id. */ + private String lastErrorEntryId; + + /** The total number of errors. */ + private int totalErrors; + + /** The number of successfully processed entries. */ + private int successfullyProcessedEntries; + + /** The start time. */ + private Date startTime; + + /** The end time. */ + private Date endTime; + + /** + * Instantiates a new batch processor. + * + * @param retryingTransactionHelper + * the retrying transaction helper + * @param collection + * the collection + * @param processName + * the process name + * @param loggingInterval + * the number of entries to process before reporting progress + * @param applicationEventPublisher + * the application event publisher + * @param workerThreads + * the number of worker threads + * @param batchSize + * the number of entries we process at a time in a transaction + */ + public BatchProcessor(RetryingTransactionHelper retryingTransactionHelper, + ApplicationEventPublisher applicationEventPublisher, Collection collection, String processName, + int loggingInterval, int workerThreads, int batchSize) + { + this.retryingTransactionHelper = retryingTransactionHelper; + this.collection = collection; + this.processName = processName; + this.loggingInterval = loggingInterval; + this.workerThreads = workerThreads; + this.batchSize = batchSize; + // Let the (enterprise) monitoring side know of our presence + applicationEventPublisher.publishEvent(new BatchMonitorEvent(this)); + } + + /* + * (non-Javadoc) + * @see org.alfresco.repo.security.sync.BatchMonitor#getCurrentEntryId() + */ + public synchronized String getCurrentEntryId() + { + return this.currentEntryId; + } + + /* + * (non-Javadoc) + * @see org.alfresco.repo.security.sync.BatchMonitor#getLastError() + */ + public synchronized String getLastError() + { + if (this.lastError == null) + { + return null; + } + Writer buff = new StringWriter(1024); + PrintWriter out = new PrintWriter(buff); + this.lastError.printStackTrace(out); + out.close(); + return buff.toString(); + } + + /* + * (non-Javadoc) + * @see org.alfresco.repo.security.sync.BatchMonitor#getLastErrorEntryId() + */ + public synchronized String getLastErrorEntryId() + { + return this.lastErrorEntryId; + } + + /* + * (non-Javadoc) + * @see org.alfresco.repo.security.sync.BatchMonitor#getBatchType() + */ + public synchronized String getProcessName() + { + return this.processName; + } + + /* + * (non-Javadoc) + * @see org.alfresco.repo.security.sync.BatchMonitor#getSuccessfullyProcessedResults() + */ + public synchronized int getSuccessfullyProcessedEntries() + { + return this.successfullyProcessedEntries; + } + + /* + * (non-Javadoc) + * @see org.alfresco.repo.security.sync.BatchMonitor#getPercentComplete() + */ + public synchronized String getPercentComplete() + { + int totalResults = this.collection.size(); + int processed = this.successfullyProcessedEntries + this.totalErrors; + return processed <= totalResults ? NumberFormat.getPercentInstance().format( + totalResults == 0 ? 1.0F : (float) processed / totalResults) : "Unknown"; + } + + /* + * (non-Javadoc) + * @see org.alfresco.repo.security.sync.BatchMonitor#getTotalErrors() + */ + public synchronized int getTotalErrors() + { + return this.totalErrors; + } + + /* + * (non-Javadoc) + * @see org.alfresco.repo.security.sync.BatchMonitor#getTotalResults() + */ + public int getTotalResults() + { + return this.collection.size(); + } + + /* + * (non-Javadoc) + * @see org.alfresco.repo.security.sync.BatchMonitor#getEndTime() + */ + public synchronized Date getEndTime() + { + return this.endTime; + } + + /* + * (non-Javadoc) + * @see org.alfresco.repo.security.sync.BatchMonitor#getStartTime() + */ + public synchronized Date getStartTime() + { + return this.startTime; + } + + /** + * Invokes the worker for each entry in the collection, managing transactions and collating success / failure + * information. + * + * @param worker + * the worker + * @param splitTxns + * Can the modifications to Alfresco be split across multiple transactions for maximum performance? If + * true, worker invocations are isolated in separate transactions in batches of 10 for + * increased performance. If false, all invocations are performed in the current + * transaction. This is required if calling synchronously (e.g. in response to an authentication event in + * the same transaction). + * @return the number of invocations + */ + @SuppressWarnings("serial") + public int process(final Worker worker, final boolean splitTxns) + { + int count = this.collection.size(); + synchronized (this) + { + this.startTime = new Date(); + if (BatchProcessor.logger.isInfoEnabled()) + { + if (count >= 0) + { + BatchProcessor.logger.info(getProcessName() + ": Commencing batch of " + count + " entries"); + } + else + { + BatchProcessor.logger.info(getProcessName() + ": Commencing batch"); + + } + } + } + + // Create a thread pool executor with the specified number of threads and a finite blocking queue of jobs + ExecutorService executorService = splitTxns && this.workerThreads > 1 ? new ThreadPoolExecutor( + this.workerThreads, this.workerThreads, 0L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue( + this.workerThreads * 10) + { + // Add blocking behaviour to work queue + @Override + public boolean offer(Runnable o) + { + try + { + put(o); + } + catch (InterruptedException e) + { + return false; + } + return true; + } + + }) : null; + try + { + Iterator iterator = this.collection.iterator(); + List batch = new ArrayList(this.batchSize); + while (iterator.hasNext()) + { + batch.add(iterator.next()); + boolean hasNext = iterator.hasNext(); + if (batch.size() >= this.batchSize || !hasNext) + { + final TxnCallback callback = new TxnCallback(worker, batch, splitTxns); + if (hasNext) + { + batch = new ArrayList(this.batchSize); + } + if (executorService == null) + { + callback.run(); + } + else + { + executorService.execute(callback); + } + } + } + return count; + } + finally + { + if (executorService != null) + { + executorService.shutdown(); + try + { + executorService.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS); + } + catch (InterruptedException e) + { + } + } + synchronized (this) + { + reportProgress(true); + this.endTime = new Date(); + if (BatchProcessor.logger.isInfoEnabled()) + { + if (count >= 0) + { + BatchProcessor.logger.info(getProcessName() + ": Completed batch of " + count + " entries"); + } + else + { + BatchProcessor.logger.info(getProcessName() + ": Completed batch"); + + } + } + if (this.totalErrors > 0 && BatchProcessor.logger.isErrorEnabled()) + { + BatchProcessor.logger.error(getProcessName() + ": " + this.totalErrors + + " error(s) detected. Last error from entry \"" + this.lastErrorEntryId + "\"", + this.lastError); + } + } + } + } + + /** + * Reports the current progress. + * + * @param last + * Have all jobs been processed? If false then progress is only reported after the number of + * entries indicated by {@link #loggingInterval}. If true then progress is reported if this + * is not one of the entries indicated by {@link #loggingInterval}. + */ + private synchronized void reportProgress(boolean last) + { + int processed = this.successfullyProcessedEntries + this.totalErrors; + if (processed % this.loggingInterval == 0 ^ last) + { + StringBuilder message = new StringBuilder(100).append(getProcessName()).append(": Processed ").append( + processed).append(" entries"); + int totalResults = this.collection.size(); + if (totalResults >= processed) + { + message.append(" out of ").append(totalResults).append(". ").append( + NumberFormat.getPercentInstance().format( + totalResults == 0 ? 1.0F : (float) processed / totalResults)).append(" complete"); + } + long duration = System.currentTimeMillis() - startTime.getTime(); + if (duration > 0) + { + message.append(". Rate: ").append(processed * 1000 / duration).append(" per second"); + } + message.append(". " + this.totalErrors + " failures detected."); + BatchProcessor.logger.info(message); + } + } + + /** + * An interface for workers to be invoked by the {@link BatchProcessor}. + */ + public interface Worker + { + + /** + * Gets an identifier for the given entry (for monitoring / logging purposes). + * + * @param entry + * the entry + * @return the identifier + */ + public String getIdentifier(T entry); + + /** + * Processes the given entry. + * + * @param entry + * the entry + * @throws Throwable + * on any error + */ + public void process(T entry) throws Throwable; + } + + /** + * A callback that invokes a worker on a batch, optionally in a new transaction. + */ + class TxnCallback implements RetryingTransactionCallback, Runnable + { + + /** + * Instantiates a new callback. + * + * @param worker + * the worker + * @param batch + * the batch to process + * @param splitTxns + * If true, the worker invocation is made in a new transaction. + */ + public TxnCallback(Worker worker, List batch, boolean splitTxns) + { + this.worker = worker; + this.batch = batch; + this.splitTxns = splitTxns; + } + + /** The worker. */ + private final Worker worker; + + /** The batch. */ + private final List batch; + + /** If true, the worker invocation is made in a new transaction. */ + private final boolean splitTxns; + + /** The total number of errors. */ + private int txnErrors; + + /** The number of successfully processed entries. */ + private int txnSuccesses; + + /** The last error. */ + private Throwable txnLastError; + + /** The last error entry id. */ + private String txnLastErrorEntryId; + + /* + * (non-Javadoc) + * @see org.alfresco.repo.transaction.RetryingTransactionHelper.RetryingTransactionCallback#execute () + */ + public Object execute() throws Throwable + { + reset(); + for (T entry : this.batch) + { + String txnEntryId = this.worker.getIdentifier(entry); + synchronized (BatchProcessor.this) + { + BatchProcessor.this.currentEntryId = txnEntryId; + } + try + { + this.worker.process(entry); + this.txnSuccesses++; + } + catch (Throwable t) + { + this.txnLastError = t; + this.txnLastErrorEntryId = txnEntryId; + this.txnErrors++; + if (RetryingTransactionHelper.extractRetryCause(t) == null) + { + if (BatchProcessor.logger.isWarnEnabled()) + { + BatchProcessor.logger.warn(getProcessName() + ": Failed to process entry \"" + txnEntryId + + "\".", t); + } + } + else + { + throw t; + } + } + } + return null; + } + + /* + * (non-Javadoc) + * @see java.lang.Runnable#run() + */ + public void run() + { + try + { + BatchProcessor.this.retryingTransactionHelper.doInTransaction(this, false, this.splitTxns); + } + catch (Throwable t) + { + // If the callback was in its own transaction, it must have run out of retries + if (this.splitTxns) + { + if (BatchProcessor.logger.isWarnEnabled()) + { + BatchProcessor.logger.warn(getProcessName() + ": Failed to process entry \"" + + BatchProcessor.this.currentEntryId + "\".", t); + } + } + // Otherwise, we have a retryable exception that we should propagate + else + { + if (t instanceof RuntimeException) + { + throw (RuntimeException) t; + } + if (t instanceof Error) + { + throw (Error) t; + } + throw new AlfrescoRuntimeException("Transactional error during " + getProcessName(), t); + } + } + commitProgress(); + } + + /** + * Resets the callback state for a retry. + */ + private void reset() + { + this.txnLastError = null; + this.txnLastErrorEntryId = null; + this.txnSuccesses = this.txnErrors = 0; + } + + /** + * Commits progress from this transaction after a successful commit. + */ + private void commitProgress() + { + synchronized (BatchProcessor.this) + { + if (this.txnErrors > 0) + { + int processed = BatchProcessor.this.successfullyProcessedEntries + BatchProcessor.this.totalErrors; + int currentIncrement = processed % BatchProcessor.this.loggingInterval; + int newErrors = BatchProcessor.this.totalErrors + this.txnErrors; + // Work out the number of logging intervals we will cross and report them + int intervals = (this.txnErrors + currentIncrement) / BatchProcessor.this.loggingInterval; + if (intervals > 0) + { + BatchProcessor.this.totalErrors += BatchProcessor.this.loggingInterval - currentIncrement; + reportProgress(false); + while (--intervals > 0) + { + BatchProcessor.this.totalErrors += BatchProcessor.this.loggingInterval; + reportProgress(false); + } + } + BatchProcessor.this.totalErrors = newErrors; + } + + if (this.txnSuccesses > 0) + { + int processed = BatchProcessor.this.successfullyProcessedEntries + BatchProcessor.this.totalErrors; + int currentIncrement = processed % BatchProcessor.this.loggingInterval; + int newSuccess = BatchProcessor.this.successfullyProcessedEntries + this.txnSuccesses; + // Work out the number of logging intervals we will cross and report them + int intervals = (this.txnSuccesses + currentIncrement) / BatchProcessor.this.loggingInterval; + if (intervals > 0) + { + BatchProcessor.this.successfullyProcessedEntries += BatchProcessor.this.loggingInterval + - currentIncrement; + reportProgress(false); + while (--intervals > 0) + { + BatchProcessor.this.successfullyProcessedEntries += BatchProcessor.this.loggingInterval; + reportProgress(false); + } + } + BatchProcessor.this.successfullyProcessedEntries = newSuccess; + } + + if (this.txnLastError != null) + { + BatchProcessor.this.lastError = this.txnLastError; + BatchProcessor.this.lastErrorEntryId = this.txnLastErrorEntryId; + } + reset(); + } + } + } + +} diff --git a/source/java/org/alfresco/repo/security/sync/ChainingUserRegistrySynchronizer.java b/source/java/org/alfresco/repo/security/sync/ChainingUserRegistrySynchronizer.java index 3c0e8771e0..cc568a38c2 100644 --- a/source/java/org/alfresco/repo/security/sync/ChainingUserRegistrySynchronizer.java +++ b/source/java/org/alfresco/repo/security/sync/ChainingUserRegistrySynchronizer.java @@ -26,12 +26,11 @@ package org.alfresco.repo.security.sync; import java.text.DateFormat; import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; import java.util.Date; import java.util.HashSet; -import java.util.Iterator; -import java.util.Map; import java.util.Set; -import java.util.TreeMap; import java.util.TreeSet; import org.alfresco.model.ContentModel; @@ -44,6 +43,7 @@ import org.alfresco.repo.management.subsystems.ActivateableBean; import org.alfresco.repo.management.subsystems.ChildApplicationContextManager; import org.alfresco.repo.security.authentication.AuthenticationUtil; import org.alfresco.repo.security.authentication.AuthenticationUtil.RunAsWork; +import org.alfresco.repo.security.sync.BatchProcessor.Worker; import org.alfresco.repo.transaction.AlfrescoTransactionSupport; import org.alfresco.repo.transaction.RetryingTransactionHelper; import org.alfresco.repo.transaction.AlfrescoTransactionSupport.TxnReadState; @@ -55,12 +55,15 @@ import org.alfresco.service.cmr.security.PersonService; import org.alfresco.service.namespace.NamespaceService; import org.alfresco.service.namespace.QName; import org.alfresco.util.AbstractLifecycleBean; +import org.alfresco.util.Pair; import org.alfresco.util.PropertyMap; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.springframework.beans.factory.NoSuchBeanDefinitionException; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationEvent; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ApplicationEventPublisherAware; /** * A ChainingUserRegistrySynchronizer is responsible for synchronizing Alfresco's local user (person) and @@ -87,12 +90,10 @@ import org.springframework.context.ApplicationEvent; * * @author dward */ -public class ChainingUserRegistrySynchronizer extends AbstractLifecycleBean implements UserRegistrySynchronizer +public class ChainingUserRegistrySynchronizer extends AbstractLifecycleBean implements UserRegistrySynchronizer, + ApplicationEventPublisherAware { - /** The number of users / groups we add at a time in a transaction *. */ - private static final int BATCH_SIZE = 10; - /** The logger. */ private static final Log logger = LogFactory.getLog(ChainingUserRegistrySynchronizer.class); @@ -133,6 +134,9 @@ public class ChainingUserRegistrySynchronizer extends AbstractLifecycleBean impl /** The job lock service. */ private JobLockService jobLockService; + /** The application event publisher. */ + private ApplicationEventPublisher applicationEventPublisher; + /** Should we trigger a differential sync when missing people log in?. */ private boolean syncWhenMissingPeopleLogIn = true; @@ -142,6 +146,12 @@ public class ChainingUserRegistrySynchronizer extends AbstractLifecycleBean impl /** Should we auto create a missing person on log in?. */ private boolean autoCreatePeopleOnLogin = true; + /** The number of entries to process before reporting progress. */ + private int loggingInterval = 100; + + /** The number of worker threads. */ + private int workerThreads = 2; + /** * Sets the application context manager. * @@ -219,6 +229,17 @@ public class ChainingUserRegistrySynchronizer extends AbstractLifecycleBean impl this.jobLockService = jobLockService; } + /* + * (non-Javadoc) + * @see + * org.springframework.context.ApplicationEventPublisherAware#setApplicationEventPublisher(org.springframework.context + * .ApplicationEventPublisher) + */ + public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) + { + this.applicationEventPublisher = applicationEventPublisher; + } + /** * Controls whether we auto create a missing person on log in. * @@ -252,91 +273,121 @@ public class ChainingUserRegistrySynchronizer extends AbstractLifecycleBean impl this.syncOnStartup = syncOnStartup; } + /** + * Sets the number of entries to process before reporting progress. + * + * @param loggingInterval + * the number of entries to process before reporting progress or zero to disable progress reporting. + */ + public void setLoggingInterval(int loggingInterval) + { + this.loggingInterval = loggingInterval; + } + + /** + * Sets the number of worker threads. + * + * @param workerThreads + * the number of worker threads + */ + public void setWorkerThreads(int workerThreads) + { + this.workerThreads = workerThreads; + } + /* * (non-Javadoc) * @see org.alfresco.repo.security.sync.UserRegistrySynchronizer#synchronize(boolean, boolean) */ public void synchronize(boolean force, boolean splitTxns) { - // First, try to obtain a lock to ensure we are the only node trying to run this job + // Let's ensure all exceptions get logged try { - if (splitTxns) - { - // If this is an automated sync on startup or scheduled sync, don't even wait around for the lock. - // Assume the sync will be completed on another node. - this.jobLockService.getTransactionalLock(ChainingUserRegistrySynchronizer.LOCK_QNAME, - ChainingUserRegistrySynchronizer.LOCK_TTL, 0, 1); - } - else - { - // If this is a login-triggered sync, give it a few retries before giving up - this.jobLockService.getTransactionalLock(ChainingUserRegistrySynchronizer.LOCK_QNAME, - ChainingUserRegistrySynchronizer.LOCK_TTL, 3000, 10); - } - } - catch (LockAcquisitionException e) - { - // Don't proceed with the sync if it is running on another node - ChainingUserRegistrySynchronizer.logger - .warn("User registry synchronization already running in another thread. Synchronize aborted"); - return; - } - - Set visitedZoneIds = new TreeSet(); - Collection instanceIds = this.applicationContextManager.getInstanceIds(); - - // Work out the set of all zone IDs in the authentication chain so that we can decide which users / groups - // need - // 're-zoning' - Set allZoneIds = new TreeSet(); - for (String id : instanceIds) - { - allZoneIds.add(AuthorityService.ZONE_AUTH_EXT_PREFIX + id); - } - for (String id : instanceIds) - { - ApplicationContext context = this.applicationContextManager.getApplicationContext(id); + // First, try to obtain a lock to ensure we are the only node trying to run this job try { - UserRegistry plugin = (UserRegistry) context.getBean(this.sourceBeanName); - if (!(plugin instanceof ActivateableBean) || ((ActivateableBean) plugin).isActive()) + if (splitTxns) { - if (ChainingUserRegistrySynchronizer.logger.isInfoEnabled()) - { - ChainingUserRegistrySynchronizer.logger - .info("Synchronizing users and groups with user registry '" + id + "'"); - } - if (force && ChainingUserRegistrySynchronizer.logger.isWarnEnabled()) - { - ChainingUserRegistrySynchronizer.logger - .warn("Forced synchronization with user registry '" - + id - + "'; some users and groups previously created by synchronization with this user registry may be removed."); - } - // Work out whether we should do the work in a separate transaction (it's most performant if we - // bunch it into small transactions, but if we are doing a sync on login, it has to be the same - // transaction) - boolean requiresNew = splitTxns - || AlfrescoTransactionSupport.getTransactionReadState() == TxnReadState.TXN_READ_ONLY; - - int personsProcessed = syncPersonsWithPlugin(id, plugin, force, requiresNew, visitedZoneIds, - allZoneIds); - int groupsProcessed = syncGroupsWithPlugin(id, plugin, force, requiresNew, visitedZoneIds, - allZoneIds); - if (ChainingUserRegistrySynchronizer.logger.isInfoEnabled()) - { - ChainingUserRegistrySynchronizer.logger - .info("Finished synchronizing users and groups with user registry '" + id + "'"); - ChainingUserRegistrySynchronizer.logger.info(personsProcessed + " user(s) and " - + groupsProcessed + " group(s) processed"); - } + // If this is an automated sync on startup or scheduled sync, don't even wait around for the lock. + // Assume the sync will be completed on another node. + this.jobLockService.getTransactionalLock(ChainingUserRegistrySynchronizer.LOCK_QNAME, + ChainingUserRegistrySynchronizer.LOCK_TTL, 0, 1); + } + else + { + // If this is a login-triggered sync, give it a few retries before giving up + this.jobLockService.getTransactionalLock(ChainingUserRegistrySynchronizer.LOCK_QNAME, + ChainingUserRegistrySynchronizer.LOCK_TTL, 3000, 10); } } - catch (NoSuchBeanDefinitionException e) + catch (LockAcquisitionException e) { - // Ignore and continue + // Don't proceed with the sync if it is running on another node + ChainingUserRegistrySynchronizer.logger + .warn("User registry synchronization already running in another thread. Synchronize aborted"); + return; } + + Set visitedZoneIds = new TreeSet(); + Collection instanceIds = this.applicationContextManager.getInstanceIds(); + + // Work out the set of all zone IDs in the authentication chain so that we can decide which users / groups + // need 're-zoning' + Set allZoneIds = new TreeSet(); + for (String id : instanceIds) + { + allZoneIds.add(AuthorityService.ZONE_AUTH_EXT_PREFIX + id); + } + for (String id : instanceIds) + { + ApplicationContext context = this.applicationContextManager.getApplicationContext(id); + try + { + UserRegistry plugin = (UserRegistry) context.getBean(this.sourceBeanName); + if (!(plugin instanceof ActivateableBean) || ((ActivateableBean) plugin).isActive()) + { + if (ChainingUserRegistrySynchronizer.logger.isInfoEnabled()) + { + ChainingUserRegistrySynchronizer.logger + .info("Synchronizing users and groups with user registry '" + id + "'"); + } + if (force && ChainingUserRegistrySynchronizer.logger.isWarnEnabled()) + { + ChainingUserRegistrySynchronizer.logger + .warn("Forced synchronization with user registry '" + + id + + "'; some users and groups previously created by synchronization with this user registry may be removed."); + } + // Work out whether we should do the work in a separate transaction (it's most performant if we + // bunch it into small transactions, but if we are doing a sync on login, it has to be the same + // transaction) + boolean requiresNew = splitTxns + || AlfrescoTransactionSupport.getTransactionReadState() == TxnReadState.TXN_READ_ONLY; + + int personsProcessed = syncPersonsWithPlugin(id, plugin, requiresNew, visitedZoneIds, + allZoneIds); + int groupsProcessed = syncGroupsWithPlugin(id, plugin, force, requiresNew, visitedZoneIds, + allZoneIds); + if (ChainingUserRegistrySynchronizer.logger.isInfoEnabled()) + { + ChainingUserRegistrySynchronizer.logger + .info("Finished synchronizing users and groups with user registry '" + id + "'"); + ChainingUserRegistrySynchronizer.logger.info(personsProcessed + " user(s) and " + + groupsProcessed + " group(s) processed"); + } + } + } + catch (NoSuchBeanDefinitionException e) + { + // Ignore and continue + } + } + } + catch (RuntimeException e) + { + ChainingUserRegistrySynchronizer.logger.error("Synchronization aborted due to error", e); + throw e; } } @@ -380,16 +431,13 @@ public class ChainingUserRegistrySynchronizer extends AbstractLifecycleBean impl } /** - * Synchronizes local users (persons) with a {@link UserRegistry} for a particular zone. + * Synchronizes changes only to local users (persons) with a {@link UserRegistry} for a particular zone. * * @param zone * the zone id. This identifier is used to tag all created users, so that in the future we can tell those * that have been deleted from the registry. * @param userRegistry * the user registry for the zone. - * @param force - * true if all persons are to be queried. false if only those changed since the - * most recent queried user should be queried. * @param splitTxns * Can the modifications to Alfresco be split across multiple transactions for maximum performance? If * true, users and groups are created/updated in batches of 10 for increased performance. If @@ -405,7 +453,7 @@ public class ChainingUserRegistrySynchronizer extends AbstractLifecycleBean impl * be 're-zoned'. * @return the number of users processed */ - private int syncPersonsWithPlugin(final String zone, UserRegistry userRegistry, boolean force, boolean splitTxns, + private int syncPersonsWithPlugin(final String zone, UserRegistry userRegistry, boolean splitTxns, final Set visitedZoneIds, final Set allZoneIds) { // Create a prefixed zone ID for use with the authority service @@ -414,7 +462,7 @@ public class ChainingUserRegistrySynchronizer extends AbstractLifecycleBean impl // The set of zones we associate with new objects (default plus registry specific) final Set zoneSet = getZones(zoneId); - final long lastModifiedMillis = force ? -1L : getMostRecentUpdateTime( + final long lastModifiedMillis = getMostRecentUpdateTime( ChainingUserRegistrySynchronizer.PERSON_LAST_MODIFIED_ATTRIBUTE, zoneId); final Date lastModified = lastModifiedMillis == -1 ? null : new Date(lastModifiedMillis); if (ChainingUserRegistrySynchronizer.logger.isInfoEnabled()) @@ -429,10 +477,10 @@ public class ChainingUserRegistrySynchronizer extends AbstractLifecycleBean impl + DateFormat.getDateTimeInstance().format(lastModified) + " from user registry '" + zone + "'"); } } - final Iterator persons = userRegistry.getPersons(lastModified); - final Set personsCreated = new TreeSet(); - - class CreationWorker implements RetryingTransactionCallback + final BatchProcessor personProcessor = new BatchProcessor( + this.retryingTransactionHelper, this.applicationEventPublisher, userRegistry.getPersons(lastModified), + zone + " User Creation", this.loggingInterval, this.workerThreads, 10); + class CreationWorker implements Worker { private long latestTime = lastModifiedMillis; @@ -441,88 +489,81 @@ public class ChainingUserRegistrySynchronizer extends AbstractLifecycleBean impl return this.latestTime; } - /* - * (non-Javadoc) - * @see org.alfresco.repo.transaction.RetryingTransactionHelper.RetryingTransactionCallback#execute() - */ - public Integer execute() throws Throwable + public String getIdentifier(NodeDescription entry) { - int processedCount = 0; - do - { - NodeDescription person = persons.next(); - PropertyMap personProperties = person.getProperties(); - String personName = (String) personProperties.get(ContentModel.PROP_USERNAME); + return entry.getSourceId(); + } - Set zones = ChainingUserRegistrySynchronizer.this.authorityService - .getAuthorityZones(personName); - if (zones == null) + public void process(NodeDescription person) throws Throwable + { + PropertyMap personProperties = person.getProperties(); + String personName = (String) personProperties.get(ContentModel.PROP_USERNAME); + Set zones = ChainingUserRegistrySynchronizer.this.authorityService + .getAuthorityZones(personName); + if (zones == null) + { + // The person did not exist at all + if (ChainingUserRegistrySynchronizer.logger.isDebugEnabled()) { - // The person did not exist at all - if (ChainingUserRegistrySynchronizer.logger.isInfoEnabled()) - { - ChainingUserRegistrySynchronizer.logger.info("Creating user '" + personName + "'"); - } - ChainingUserRegistrySynchronizer.this.personService.createPerson(personProperties, zoneSet); + ChainingUserRegistrySynchronizer.logger.debug("Creating user '" + personName + "'"); } - else if (zones.contains(zoneId)) + ChainingUserRegistrySynchronizer.this.personService.createPerson(personProperties, zoneSet); + } + else if (zones.contains(zoneId)) + { + // The person already existed in this zone: update the person + if (ChainingUserRegistrySynchronizer.logger.isDebugEnabled()) { - // The person already existed in this zone: update the person - if (ChainingUserRegistrySynchronizer.logger.isInfoEnabled()) + ChainingUserRegistrySynchronizer.logger.debug("Updating user '" + personName + "'"); + } + ChainingUserRegistrySynchronizer.this.personService.setPersonProperties(personName, + personProperties); + } + else + { + // Check whether the user is in any of the authentication chain zones + Set intersection = new TreeSet(zones); + intersection.retainAll(allZoneIds); + if (intersection.size() == 0) + { + // The person exists, but not in a zone that's in the authentication chain. May be due + // to upgrade or zone changes. Let's re-zone them + if (ChainingUserRegistrySynchronizer.logger.isWarnEnabled()) { - ChainingUserRegistrySynchronizer.logger.info("Updating user '" + personName + "'"); + ChainingUserRegistrySynchronizer.logger.warn("Updating user '" + personName + + "'. This user will in future be assumed to originate from user registry '" + zone + + "'."); } + ChainingUserRegistrySynchronizer.this.authorityService.removeAuthorityFromZones(personName, + zones); + ChainingUserRegistrySynchronizer.this.authorityService.addAuthorityToZones(personName, zoneSet); ChainingUserRegistrySynchronizer.this.personService.setPersonProperties(personName, personProperties); } else { - // Check whether the user is in any of the authentication chain zones - Set intersection = new TreeSet(zones); - intersection.retainAll(allZoneIds); - if (intersection.size() == 0) + // Check whether the user is in any of the higher priority authentication chain zones + intersection.retainAll(visitedZoneIds); + if (intersection.size() > 0) { - // The person exists, but not in a zone that's in the authentication chain. May be due to - // upgrade or zone changes. Let's re-zone them - if (ChainingUserRegistrySynchronizer.logger.isWarnEnabled()) - { - ChainingUserRegistrySynchronizer.logger.warn("Updating user '" + personName - + "'. This user will in future be assumed to originate from user registry '" - + zone + "'."); - } - ChainingUserRegistrySynchronizer.this.authorityService.removeAuthorityFromZones(personName, - zones); - ChainingUserRegistrySynchronizer.this.authorityService.addAuthorityToZones(personName, - zoneSet); - ChainingUserRegistrySynchronizer.this.personService.setPersonProperties(personName, - personProperties); + // A person that exists in a different zone with higher precedence - ignore + return; } - else - { - // Check whether the user is in any of the higher priority authentication chain zones - intersection.retainAll(visitedZoneIds); - if (intersection.size() > 0) - { - // A person that exists in a different zone with higher precedence - ignore - continue; - } - // The person existed, but in a zone with lower precedence - if (ChainingUserRegistrySynchronizer.logger.isWarnEnabled()) - { - ChainingUserRegistrySynchronizer.logger - .warn("Recreating occluded user '" - + personName - + "'. This user was previously created through synchronization with a lower priority user registry."); - } - ChainingUserRegistrySynchronizer.this.personService.deletePerson(personName); - ChainingUserRegistrySynchronizer.this.personService.createPerson(personProperties, zoneSet); + // The person existed, but in a zone with lower precedence + if (ChainingUserRegistrySynchronizer.logger.isWarnEnabled()) + { + ChainingUserRegistrySynchronizer.logger + .warn("Recreating occluded user '" + + personName + + "'. This user was previously created through synchronization with a lower priority user registry."); } + ChainingUserRegistrySynchronizer.this.personService.deletePerson(personName); + ChainingUserRegistrySynchronizer.this.personService.createPerson(personProperties, zoneSet); } - // Increment the count of processed people - personsCreated.add(personName); - processedCount++; - + } + synchronized (this) + { // Maintain the last modified date Date personLastModified = person.getLastModified(); if (personLastModified != null) @@ -530,54 +571,16 @@ public class ChainingUserRegistrySynchronizer extends AbstractLifecycleBean impl this.latestTime = Math.max(this.latestTime, personLastModified.getTime()); } } - while (persons.hasNext() && processedCount < ChainingUserRegistrySynchronizer.BATCH_SIZE); - return processedCount; } } CreationWorker creations = new CreationWorker(); - int processedCount = 0; - while (persons.hasNext()) - { - processedCount += this.retryingTransactionHelper.doInTransaction(creations, false, splitTxns); - } + int processedCount = personProcessor.process(creations, splitTxns); long latestTime = creations.getLatestTime(); if (latestTime != -1) { - setMostRecentUpdateTime(ChainingUserRegistrySynchronizer.PERSON_LAST_MODIFIED_ATTRIBUTE, zoneId, latestTime); - } - - // Handle deletions if we are doing a full sync - if (force) - { - class DeletionWorker implements RetryingTransactionCallback - { - /* - * (non-Javadoc) - * @see org.alfresco.repo.transaction.RetryingTransactionHelper.RetryingTransactionCallback#execute() - */ - public Integer execute() throws Throwable - { - int processedCount = 0; - Set personsToDelete = ChainingUserRegistrySynchronizer.this.authorityService - .getAllAuthoritiesInZone(zoneId, AuthorityType.USER); - personsToDelete.removeAll(personsCreated); - for (String personName : personsToDelete) - { - if (ChainingUserRegistrySynchronizer.logger.isWarnEnabled()) - { - ChainingUserRegistrySynchronizer.logger.warn("Deleting user '" + personName + "'"); - } - ChainingUserRegistrySynchronizer.this.personService.deletePerson(personName); - processedCount++; - } - return processedCount; - } - } - - // Just use a single transaction - DeletionWorker deletions = new DeletionWorker(); - processedCount += this.retryingTransactionHelper.doInTransaction(deletions, false, splitTxns); + setMostRecentUpdateTime(ChainingUserRegistrySynchronizer.PERSON_LAST_MODIFIED_ATTRIBUTE, zoneId, + latestTime, splitTxns); } // Remember we have visited this zone @@ -587,7 +590,8 @@ public class ChainingUserRegistrySynchronizer extends AbstractLifecycleBean impl } /** - * Synchronizes local groups (authorities) with a {@link UserRegistry} for a particular zone. + * Synchronizes local groups with a {@link UserRegistry} for a particular zone and also handles deletions of local + * groups and users. * * @param zone * the zone id. This identifier is used to tag all created groups, so that in the future we can tell @@ -595,8 +599,7 @@ public class ChainingUserRegistrySynchronizer extends AbstractLifecycleBean impl * @param userRegistry * the user registry for the zone. * @param force - * true if all groups are to be queried. false if only those changed since the - * most recent queried group should be queried. + * true if user and group deletions are to be processed. * @param splitTxns * Can the modifications to Alfresco be split across multiple transactions for maximum performance? If * true, users and groups are created/updated in batches of 10 for increased performance. If @@ -621,7 +624,7 @@ public class ChainingUserRegistrySynchronizer extends AbstractLifecycleBean impl // The set of zones we associate with new objects (default plus registry specific) final Set zoneSet = getZones(zoneId); - final long lastModifiedMillis = force ? -1L : getMostRecentUpdateTime( + final long lastModifiedMillis = getMostRecentUpdateTime( ChainingUserRegistrySynchronizer.GROUP_LAST_MODIFIED_ATTRIBUTE, zoneId); final Date lastModified = lastModifiedMillis == -1 ? null : new Date(lastModifiedMillis); @@ -638,12 +641,37 @@ public class ChainingUserRegistrySynchronizer extends AbstractLifecycleBean impl } } - final Iterator groups = userRegistry.getGroups(lastModified); - final Map> groupAssocsToCreate = new TreeMap>(); - final Set groupsCreated = new TreeSet(); + // Get current set of known authorities + Set deletionCandidates = this.retryingTransactionHelper.doInTransaction( + new RetryingTransactionCallback>() + { + public Set execute() throws Throwable + { + return ChainingUserRegistrySynchronizer.this.authorityService.getAllAuthoritiesInZone(zoneId, + null); + } + }, false, splitTxns); - class CreationWorker implements RetryingTransactionCallback + final BatchProcessor groupProcessor = new BatchProcessor( + this.retryingTransactionHelper, this.applicationEventPublisher, userRegistry.getGroups(lastModified, + deletionCandidates, force), zone + " Group Creation", this.loggingInterval, this.workerThreads, + 20); + class CreationWorker implements Worker { + private final Set> groupAssocsToCreate = new TreeSet>( + new Comparator>() + { + + public int compare(Pair o1, Pair o2) + { + int result = o1.getFirst().compareTo(o2.getFirst()); + if (result == 0) + { + return o1.getSecond().compareTo(o2.getSecond()); + } + return result; + } + }); private long latestTime = lastModifiedMillis; public long getLatestTime() @@ -651,30 +679,123 @@ public class ChainingUserRegistrySynchronizer extends AbstractLifecycleBean impl return this.latestTime; } + public Set> getGroupAssocsToCreate() + { + return this.groupAssocsToCreate; + } + /* * (non-Javadoc) - * @see org.alfresco.repo.transaction.RetryingTransactionHelper.RetryingTransactionCallback#execute() + * @see org.alfresco.repo.security.sync.BatchProcessor.Worker#getIdentifier(java.lang.Object) */ - public Integer execute() throws Throwable + public String getIdentifier(NodeDescription entry) { - int processedCount = 0; - do - { - NodeDescription group = groups.next(); - PropertyMap groupProperties = group.getProperties(); - String groupName = (String) groupProperties.get(ContentModel.PROP_AUTHORITY_NAME); - String groupShortName = ChainingUserRegistrySynchronizer.this.authorityService - .getShortName(groupName); - Set groupZones = ChainingUserRegistrySynchronizer.this.authorityService - .getAuthorityZones(groupName); + return entry.getSourceId(); + } - if (groupZones == null) + /* + * (non-Javadoc) + * @see org.alfresco.repo.security.sync.BatchProcessor.Worker#process(java.lang.Object) + */ + public void process(NodeDescription group) throws Throwable + { + PropertyMap groupProperties = group.getProperties(); + String groupName = (String) groupProperties.get(ContentModel.PROP_AUTHORITY_NAME); + String groupShortName = ChainingUserRegistrySynchronizer.this.authorityService.getShortName(groupName); + Set groupZones = ChainingUserRegistrySynchronizer.this.authorityService + .getAuthorityZones(groupName); + + if (groupZones == null) + { + // The group did not exist at all + if (ChainingUserRegistrySynchronizer.logger.isDebugEnabled()) { - // The group did not exist at all - if (ChainingUserRegistrySynchronizer.logger.isInfoEnabled()) + ChainingUserRegistrySynchronizer.logger.debug("Creating group '" + groupShortName + "'"); + } + // create the group + ChainingUserRegistrySynchronizer.this.authorityService.createAuthority(AuthorityType + .getAuthorityType(groupName), groupShortName, (String) groupProperties + .get(ContentModel.PROP_AUTHORITY_DISPLAY_NAME), zoneSet); + Set children = group.getChildAssociations(); + if (!children.isEmpty()) + { + synchronized (this) { - ChainingUserRegistrySynchronizer.logger.info("Creating group '" + groupShortName + "'"); + for (String child : children) + { + this.groupAssocsToCreate.add(new Pair(groupName, child)); + } } + } + } + else + { + // Check whether the group is in any of the authentication chain zones + Set intersection = new TreeSet(groupZones); + intersection.retainAll(allZoneIds); + if (intersection.isEmpty()) + { + // The group exists, but not in a zone that's in the authentication chain. May be due to + // upgrade or zone changes. Let's re-zone them + if (ChainingUserRegistrySynchronizer.logger.isWarnEnabled()) + { + ChainingUserRegistrySynchronizer.logger.warn("Updating group '" + groupShortName + + "'. This group will in future be assumed to originate from user registry '" + + zone + "'."); + } + ChainingUserRegistrySynchronizer.this.authorityService.removeAuthorityFromZones(groupName, + groupZones); + ChainingUserRegistrySynchronizer.this.authorityService.addAuthorityToZones(groupName, zoneSet); + } + if (groupZones.contains(zoneId) || intersection.isEmpty()) + { + // The group already existed in this zone or no valid zone: update the group + Set oldChildren = ChainingUserRegistrySynchronizer.this.authorityService + .getContainedAuthorities(null, groupName, true); + Set newChildren = group.getChildAssociations(); + Set toDelete = new TreeSet(oldChildren); + Set toAdd = new TreeSet(newChildren); + toDelete.removeAll(newChildren); + toAdd.removeAll(oldChildren); + if (!toAdd.isEmpty()) + { + synchronized (this) + { + for (String child : toAdd) + { + this.groupAssocsToCreate.add(new Pair(groupName, child)); + } + } + } + for (String child : toDelete) + { + if (ChainingUserRegistrySynchronizer.logger.isDebugEnabled()) + { + ChainingUserRegistrySynchronizer.logger.debug("Removing '" + + ChainingUserRegistrySynchronizer.this.authorityService.getShortName(child) + + "' from group '" + groupShortName + "'"); + } + ChainingUserRegistrySynchronizer.this.authorityService.removeAuthority(groupName, child); + } + } + else + { + // Check whether the group is in any of the higher priority authentication chain zones + intersection.retainAll(visitedZoneIds); + if (!intersection.isEmpty()) + { + // A group that exists in a different zone with higher precedence + return; + } + // The group existed, but in a zone with lower precedence + if (ChainingUserRegistrySynchronizer.logger.isWarnEnabled()) + { + ChainingUserRegistrySynchronizer.logger + .warn("Recreating occluded group '" + + groupShortName + + "'. This group was previously created through synchronization with a lower priority user registry."); + } + ChainingUserRegistrySynchronizer.this.authorityService.deleteAuthority(groupName); // create the group ChainingUserRegistrySynchronizer.this.authorityService.createAuthority(AuthorityType .getAuthorityType(groupName), groupShortName, (String) groupProperties @@ -682,204 +803,101 @@ public class ChainingUserRegistrySynchronizer extends AbstractLifecycleBean impl Set children = group.getChildAssociations(); if (!children.isEmpty()) { - groupAssocsToCreate.put(groupName, children); - } - } - else - { - // Check whether the group is in any of the authentication chain zones - Set intersection = new TreeSet(groupZones); - intersection.retainAll(allZoneIds); - if (intersection.isEmpty()) - { - // The group exists, but not in a zone that's in the authentication chain. May be due to - // upgrade or zone changes. Let's re-zone them - if (ChainingUserRegistrySynchronizer.logger.isWarnEnabled()) + synchronized (this) { - ChainingUserRegistrySynchronizer.logger.warn("Updating group '" + groupShortName - + "'. This group will in future be assumed to originate from user registry '" - + zone + "'."); - } - ChainingUserRegistrySynchronizer.this.authorityService.removeAuthorityFromZones(groupName, - groupZones); - ChainingUserRegistrySynchronizer.this.authorityService.addAuthorityToZones(groupName, - zoneSet); - } - if (groupZones.contains(zoneId) || intersection.isEmpty()) - { - // The group already existed in this zone or no valid zone: update the group - Set oldChildren = ChainingUserRegistrySynchronizer.this.authorityService - .getContainedAuthorities(null, groupName, true); - Set newChildren = group.getChildAssociations(); - Set toDelete = new TreeSet(oldChildren); - Set toAdd = new TreeSet(newChildren); - toDelete.removeAll(newChildren); - toAdd.removeAll(oldChildren); - if (!toAdd.isEmpty()) - { - groupAssocsToCreate.put(groupName, toAdd); - } - for (String child : toDelete) - { - if (ChainingUserRegistrySynchronizer.logger.isInfoEnabled()) + for (String child : children) { - ChainingUserRegistrySynchronizer.logger.info("Removing '" - + ChainingUserRegistrySynchronizer.this.authorityService - .getShortName(child) + "' from group '" + groupShortName + "'"); + this.groupAssocsToCreate.add(new Pair(groupName, child)); } - ChainingUserRegistrySynchronizer.this.authorityService - .removeAuthority(groupName, child); - } - } - else - { - // Check whether the group is in any of the higher priority authentication chain zones - intersection.retainAll(visitedZoneIds); - if (!intersection.isEmpty()) - { - // A group that exists in a different zone with higher precedence - continue; - } - // The group existed, but in a zone with lower precedence - if (ChainingUserRegistrySynchronizer.logger.isWarnEnabled()) - { - ChainingUserRegistrySynchronizer.logger - .warn("Recreating occluded group '" - + groupShortName - + "'. This group was previously created through synchronization with a lower priority user registry."); - } - ChainingUserRegistrySynchronizer.this.authorityService.deleteAuthority(groupName); - // create the group - ChainingUserRegistrySynchronizer.this.authorityService.createAuthority(AuthorityType - .getAuthorityType(groupName), groupShortName, (String) groupProperties - .get(ContentModel.PROP_AUTHORITY_DISPLAY_NAME), zoneSet); - Set children = group.getChildAssociations(); - if (!children.isEmpty()) - { - groupAssocsToCreate.put(groupName, children); } } } - // Increment the count of processed groups - processedCount++; - groupsCreated.add(groupName); + } + synchronized (this) + { // Maintain the last modified date Date groupLastModified = group.getLastModified(); if (groupLastModified != null) { this.latestTime = Math.max(this.latestTime, groupLastModified.getTime()); } - } - while (groups.hasNext() && processedCount < ChainingUserRegistrySynchronizer.BATCH_SIZE); - return processedCount; } } CreationWorker creations = new CreationWorker(); - int processedCount = 0; - while (groups.hasNext()) - { - processedCount += this.retryingTransactionHelper.doInTransaction(creations, false, splitTxns); - } + int processedCount = groupProcessor.process(creations, splitTxns); long latestTime = creations.getLatestTime(); if (latestTime != -1) { - setMostRecentUpdateTime(ChainingUserRegistrySynchronizer.GROUP_LAST_MODIFIED_ATTRIBUTE, zoneId, latestTime); + setMostRecentUpdateTime(ChainingUserRegistrySynchronizer.GROUP_LAST_MODIFIED_ATTRIBUTE, zoneId, latestTime, + splitTxns); } // Add the new associations, now that we have created everything - - final Iterator>> groupAssocs = groupAssocsToCreate.entrySet().iterator(); - class AssocWorker implements RetryingTransactionCallback + BatchProcessor> groupAssocProcessor = new BatchProcessor>( + this.retryingTransactionHelper, this.applicationEventPublisher, creations.getGroupAssocsToCreate(), + zone + " Group Association Creation", this.loggingInterval, this.workerThreads, 20); + groupAssocProcessor.process(new Worker>() { - /* - * (non-Javadoc) - * @see org.alfresco.repo.transaction.RetryingTransactionHelper.RetryingTransactionCallback#execute() - */ - public Integer execute() throws Throwable + + public String getIdentifier(Pair entry) { - int processedCount = 0; - do - { - - Map.Entry> entry = groupAssocs.next(); - for (String child : entry.getValue()) - { - String groupName = entry.getKey(); - if (ChainingUserRegistrySynchronizer.logger.isInfoEnabled()) - { - ChainingUserRegistrySynchronizer.logger.info("Adding '" - + ChainingUserRegistrySynchronizer.this.authorityService.getShortName(child) - + "' to group '" - + ChainingUserRegistrySynchronizer.this.authorityService.getShortName(groupName) - + "'"); - } - try - { - ChainingUserRegistrySynchronizer.this.authorityService.addAuthority(groupName, child); - } - catch (Exception e) - { - // Let's not allow referential integrity problems (dangling references) kill the whole - // process - if (ChainingUserRegistrySynchronizer.logger.isWarnEnabled()) - { - ChainingUserRegistrySynchronizer.logger.warn("Failed to add '" - + ChainingUserRegistrySynchronizer.this.authorityService.getShortName(child) - + "' to group '" - + ChainingUserRegistrySynchronizer.this.authorityService - .getShortName(groupName) + "'", e); - } - } - - } - } - while (groupAssocs.hasNext() && processedCount < ChainingUserRegistrySynchronizer.BATCH_SIZE); - return processedCount; - + return entry.toString(); } - } - AssocWorker assocs = new AssocWorker(); - while (groupAssocs.hasNext()) - { - this.retryingTransactionHelper.doInTransaction(assocs, false, splitTxns); - } + public void process(Pair entry) throws Throwable + { + String groupName = entry.getFirst(); + String child = entry.getSecond(); + if (ChainingUserRegistrySynchronizer.logger.isDebugEnabled()) + { + ChainingUserRegistrySynchronizer.logger.debug("Adding '" + + ChainingUserRegistrySynchronizer.this.authorityService.getShortName(child) + + "' to group '" + + ChainingUserRegistrySynchronizer.this.authorityService.getShortName(groupName) + "'"); + } + ChainingUserRegistrySynchronizer.this.authorityService.addAuthority(groupName, child); + } + }, splitTxns); - // Delete groups if we have complete information for the zone + // Delete authorities if we have complete information for the zone if (force) { - class DeletionWorker implements RetryingTransactionCallback + BatchProcessor authorityDeletionProcessor = new BatchProcessor( + this.retryingTransactionHelper, this.applicationEventPublisher, deletionCandidates, zone + + " Authority Deletion", this.loggingInterval, this.workerThreads, 10); + processedCount += authorityDeletionProcessor.process(new Worker() { - /* - * (non-Javadoc) - * @see org.alfresco.repo.transaction.RetryingTransactionHelper.RetryingTransactionCallback#execute() - */ - public Integer execute() throws Throwable - { - int processedCount = 0; - Set groupsToDelete = ChainingUserRegistrySynchronizer.this.authorityService - .getAllAuthoritiesInZone(zoneId, AuthorityType.GROUP); - groupsToDelete.removeAll(groupsCreated); - for (String group : groupsToDelete) - { - if (ChainingUserRegistrySynchronizer.logger.isWarnEnabled()) - { - ChainingUserRegistrySynchronizer.logger.warn("Deleting group '" - + ChainingUserRegistrySynchronizer.this.authorityService.getShortName(group) + "'"); - } - ChainingUserRegistrySynchronizer.this.authorityService.deleteAuthority(group); - processedCount++; - } - return processedCount; - } - } - // Just use a single transaction - DeletionWorker deletions = new DeletionWorker(); - processedCount += this.retryingTransactionHelper.doInTransaction(deletions, false, splitTxns); + public String getIdentifier(String entry) + { + return entry; + } + + public void process(String authority) throws Throwable + { + if (AuthorityType.getAuthorityType(authority) == AuthorityType.USER) + { + if (ChainingUserRegistrySynchronizer.logger.isDebugEnabled()) + { + ChainingUserRegistrySynchronizer.logger.debug("Deleting user '" + authority + "'"); + } + ChainingUserRegistrySynchronizer.this.personService.deletePerson(authority); + } + else + { + if (ChainingUserRegistrySynchronizer.logger.isDebugEnabled()) + { + ChainingUserRegistrySynchronizer.logger.debug("Deleting group '" + + ChainingUserRegistrySynchronizer.this.authorityService.getShortName(authority) + + "'"); + } + ChainingUserRegistrySynchronizer.this.authorityService.deleteAuthority(authority); + } + } + }, splitTxns); } // Remember we have visited this zone @@ -913,21 +931,38 @@ public class ChainingUserRegistrySynchronizer extends AbstractLifecycleBean impl * the zone id * @param lastModifiedMillis * the update time in milliseconds + * @param splitTxns + * Can the modifications to Alfresco be split across multiple transactions for maximum performance? If + * true, the attribute is persisted in a new transaction for increased performance and + * reliability. */ - private void setMostRecentUpdateTime(String label, String zoneId, long lastModifiedMillis) + private void setMostRecentUpdateTime(final String label, final String zoneId, final long lastModifiedMillis, + boolean splitTxns) { - String path = ChainingUserRegistrySynchronizer.ROOT_ATTRIBUTE_PATH + '/' + label; - if (!this.attributeService.exists(path)) - { - if (!this.attributeService.exists(ChainingUserRegistrySynchronizer.ROOT_ATTRIBUTE_PATH)) - { - this.attributeService.setAttribute("", ChainingUserRegistrySynchronizer.ROOT_ATTRIBUTE_PATH, - new MapAttributeValue()); - } - this.attributeService.setAttribute(ChainingUserRegistrySynchronizer.ROOT_ATTRIBUTE_PATH, label, - new MapAttributeValue()); - } - this.attributeService.setAttribute(path, zoneId, new LongAttributeValue(lastModifiedMillis)); + final String path = ChainingUserRegistrySynchronizer.ROOT_ATTRIBUTE_PATH + '/' + label; + this.retryingTransactionHelper.doInTransaction( + new RetryingTransactionHelper.RetryingTransactionCallback() + { + + public Object execute() throws Throwable + { + if (!ChainingUserRegistrySynchronizer.this.attributeService.exists(path)) + { + if (!ChainingUserRegistrySynchronizer.this.attributeService + .exists(ChainingUserRegistrySynchronizer.ROOT_ATTRIBUTE_PATH)) + { + ChainingUserRegistrySynchronizer.this.attributeService.setAttribute("", + ChainingUserRegistrySynchronizer.ROOT_ATTRIBUTE_PATH, new MapAttributeValue()); + } + ChainingUserRegistrySynchronizer.this.attributeService.setAttribute( + ChainingUserRegistrySynchronizer.ROOT_ATTRIBUTE_PATH, label, + new MapAttributeValue()); + } + ChainingUserRegistrySynchronizer.this.attributeService.setAttribute(path, zoneId, + new LongAttributeValue(lastModifiedMillis)); + return null; + } + }, false, splitTxns); } /** @@ -941,7 +976,7 @@ public class ChainingUserRegistrySynchronizer extends AbstractLifecycleBean impl */ private Set getZones(String zoneId) { - Set zones = new HashSet(2, 1.0f); + Set zones = new HashSet(5); zones.add(AuthorityService.ZONE_APP_DEFAULT); zones.add(zoneId); return zones; @@ -993,5 +1028,4 @@ public class ChainingUserRegistrySynchronizer extends AbstractLifecycleBean impl protected void onShutdown(ApplicationEvent event) { } - } diff --git a/source/java/org/alfresco/repo/security/sync/ChainingUserRegistrySynchronizerTest.java b/source/java/org/alfresco/repo/security/sync/ChainingUserRegistrySynchronizerTest.java index ef3292b9ec..4e677b31c6 100644 --- a/source/java/org/alfresco/repo/security/sync/ChainingUserRegistrySynchronizerTest.java +++ b/source/java/org/alfresco/repo/security/sync/ChainingUserRegistrySynchronizerTest.java @@ -24,6 +24,8 @@ */ package org.alfresco.repo.security.sync; +import java.util.AbstractCollection; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; @@ -31,6 +33,7 @@ import java.util.Date; import java.util.HashSet; import java.util.Iterator; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Set; @@ -46,6 +49,7 @@ import org.alfresco.service.cmr.repository.NodeService; import org.alfresco.service.cmr.security.AuthorityService; import org.alfresco.service.cmr.security.AuthorityType; import org.alfresco.service.cmr.security.PersonService; +import org.alfresco.util.GUID; import org.alfresco.util.PropertyMap; import org.springframework.context.ApplicationContext; import org.springframework.context.support.ClassPathXmlApplicationContext; @@ -65,7 +69,7 @@ public class ChainingUserRegistrySynchronizerTest extends TestCase "classpath:alfresco/application-context.xml", "classpath:sync-test-context.xml" }; - /** The Spring application context */ + /** The Spring application context. */ private static ApplicationContext context = new ClassPathXmlApplicationContext( ChainingUserRegistrySynchronizerTest.CONFIG_LOCATIONS); @@ -90,6 +94,10 @@ public class ChainingUserRegistrySynchronizerTest extends TestCase /** The retrying transaction helper. */ private RetryingTransactionHelper retryingTransactionHelper; + /* + * (non-Javadoc) + * @see junit.framework.TestCase#setUp() + */ @Override protected void setUp() throws Exception { @@ -110,6 +118,10 @@ public class ChainingUserRegistrySynchronizerTest extends TestCase .getBean("retryingTransactionHelper"); } + /* + * (non-Javadoc) + * @see junit.framework.TestCase#tearDown() + */ @Override protected void tearDown() throws Exception { @@ -191,6 +203,12 @@ public class ChainingUserRegistrySynchronizerTest extends TestCase }); } + /** + * Tear down test users and groups. + * + * @throws Exception + * the exception + */ private void tearDownTestUsersAndGroups() throws Exception { // Wipe out everything that was in Z1 and Z2 @@ -301,7 +319,7 @@ public class ChainingUserRegistrySynchronizerTest extends TestCase *
      * Z1
      * G1 - U6
-     * G2 - 
+     * G2 -
      * G3 - U2, G5 - U6
      * G6 - u3
      * 
@@ -365,7 +383,30 @@ public class ChainingUserRegistrySynchronizerTest extends TestCase
     }
 
     /**
-     * Constructs a description of a test group
+     * Tests synchronization with a zone with a larger volume of authorities.
+     * 
+     * @throws Exception
+     *             the exception
+     */
+    public void testVolume() throws Exception
+    {
+        List persons = new ArrayList(new RandomPersonCollection(100));
+        List groups = new ArrayList(new RandomGroupCollection(100, persons));
+        this.applicationContextManager.setUserRegistries(new MockUserRegistry("Z0", persons, groups));
+        this.retryingTransactionHelper.doInTransaction(new RetryingTransactionCallback()
+        {
+
+            public Object execute() throws Throwable
+            {
+                ChainingUserRegistrySynchronizerTest.this.synchronizer.synchronize(true, true);
+                return null;
+            }
+        });
+        tearDownTestUsersAndGroups();
+    }
+
+    /**
+     * Constructs a description of a test group.
      * 
      * @param name
      *            the name
@@ -375,9 +416,10 @@ public class ChainingUserRegistrySynchronizerTest extends TestCase
      */
     private NodeDescription newGroup(String name, String... members)
     {
-        NodeDescription group = new NodeDescription();
+        String longName = longName(name);
+        NodeDescription group = new NodeDescription(longName);
         PropertyMap properties = group.getProperties();
-        properties.put(ContentModel.PROP_AUTHORITY_NAME, longName(name));
+        properties.put(ContentModel.PROP_AUTHORITY_NAME, longName);
         if (members.length > 0)
         {
             Set assocs = group.getChildAssociations();
@@ -413,7 +455,7 @@ public class ChainingUserRegistrySynchronizerTest extends TestCase
      */
     private NodeDescription newPerson(String userName, String email)
     {
-        NodeDescription person = new NodeDescription();
+        NodeDescription person = new NodeDescription(userName);
         PropertyMap properties = person.getProperties();
         properties.put(ContentModel.PROP_USERNAME, userName);
         properties.put(ContentModel.PROP_FIRSTNAME, userName + "F");
@@ -481,7 +523,7 @@ public class ChainingUserRegistrySynchronizerTest extends TestCase
     }
 
     /**
-     * Asserts that a person's email has the expected value
+     * Asserts that a person's email has the expected value.
      * 
      * @param personName
      *            the person name
@@ -518,10 +560,27 @@ public class ChainingUserRegistrySynchronizerTest extends TestCase
         private String zoneId;
 
         /** The persons. */
-        private NodeDescription[] persons;
+        private Collection persons;
 
         /** The groups. */
-        private NodeDescription[] groups;
+        private Collection groups;
+
+        /**
+         * Instantiates a new mock user registry.
+         * 
+         * @param zoneId
+         *            the zone id
+         * @param persons
+         *            the persons
+         * @param groups
+         *            the groups
+         */
+        public MockUserRegistry(String zoneId, Collection persons, Collection groups)
+        {
+            this.zoneId = zoneId;
+            this.persons = persons;
+            this.groups = groups;
+        }
 
         /**
          * Instantiates a new mock user registry.
@@ -535,9 +594,7 @@ public class ChainingUserRegistrySynchronizerTest extends TestCase
          */
         public MockUserRegistry(String zoneId, NodeDescription[] persons, NodeDescription[] groups)
         {
-            this.zoneId = zoneId;
-            this.persons = persons;
-            this.groups = groups;
+            this(zoneId, Arrays.asList(persons), Arrays.asList(groups));
         }
 
         /**
@@ -552,20 +609,32 @@ public class ChainingUserRegistrySynchronizerTest extends TestCase
 
         /*
          * (non-Javadoc)
-         * @see org.alfresco.repo.security.sync.UserRegistry#getGroups(java.util.Date)
+         * @see org.alfresco.repo.security.sync.UserRegistry#getGroups(java.util.Date, java.util.Set, boolean)
          */
-        public Iterator getGroups(Date modifiedSince)
+        public Collection getGroups(Date modifiedSince, Set candidateAuthoritiesForDeletion,
+                boolean prune)
         {
-            return Arrays.asList(this.groups).iterator();
+            if (prune)
+            {
+                for (NodeDescription person : this.persons)
+                {
+                    candidateAuthoritiesForDeletion.remove(person.getProperties().get(ContentModel.PROP_USERNAME));
+                }
+                for (NodeDescription group : this.groups)
+                {
+                    candidateAuthoritiesForDeletion.remove(group.getProperties().get(ContentModel.PROP_AUTHORITY_NAME));
+                }
+            }
+            return this.groups;
         }
 
         /*
          * (non-Javadoc)
          * @see org.alfresco.repo.security.sync.UserRegistry#getPersons(java.util.Date)
          */
-        public Iterator getPersons(Date modifiedSince)
+        public Collection getPersons(Date modifiedSince)
         {
-            return Arrays.asList(this.persons).iterator();
+            return this.persons;
         }
     }
 
@@ -615,4 +684,144 @@ public class ChainingUserRegistrySynchronizerTest extends TestCase
             return this.contexts.keySet();
         }
     }
+
+    /**
+     * A collection whose iterator returns randomly generated persons.
+     */
+    public class RandomPersonCollection extends AbstractCollection
+    {
+
+        /** The collection size. */
+        private final int size;
+
+        /**
+         * The Constructor.
+         * 
+         * @param size
+         *            the collection size
+         */
+        public RandomPersonCollection(int size)
+        {
+            this.size = size;
+        }
+
+        /*
+         * (non-Javadoc)
+         * @see java.util.AbstractCollection#iterator()
+         */
+        @Override
+        public Iterator iterator()
+        {
+            return new Iterator()
+            {
+
+                private int pos;
+
+                public boolean hasNext()
+                {
+                    return pos < size;
+                }
+
+                public NodeDescription next()
+                {
+                    pos++;
+                    return newPerson("U" + GUID.generate());
+                }
+
+                public void remove()
+                {
+                    throw new UnsupportedOperationException();
+                }
+            };
+
+        }
+
+        /*
+         * (non-Javadoc)
+         * @see java.util.AbstractCollection#size()
+         */
+        @Override
+        public int size()
+        {
+            return size;
+        }
+
+    }
+
+    /**
+     * A collection whose iterator returns randomly generated groups with random associations to a given list of
+     * persons.
+     */
+    public class RandomGroupCollection extends AbstractCollection
+    {
+
+        /** The collection size. */
+        private final int size;
+
+        /** The persons. */
+        private final List persons;
+
+        /**
+         * The Constructor.
+         * 
+         * @param size
+         *            the collection size
+         * @param persons
+         *            the persons
+         */
+        public RandomGroupCollection(int size, List persons)
+        {
+            this.size = size;
+            this.persons = persons;
+        }
+
+        /*
+         * (non-Javadoc)
+         * @see java.util.AbstractCollection#iterator()
+         */
+        @Override
+        public Iterator iterator()
+        {
+            return new Iterator()
+            {
+
+                private int pos;
+
+                public boolean hasNext()
+                {
+                    return pos < size;
+                }
+
+                public NodeDescription next()
+                {
+                    pos++;
+                    String[] personNames = new String[10];
+                    for (int i = 0; i < personNames.length; i++)
+                    {
+                        personNames[i] = (String) persons.get((int) (Math.random() * (double) (persons.size() - 1)))
+                                .getProperties().get(ContentModel.PROP_USERNAME);
+                    }
+                    return newGroup("G" + GUID.generate(), personNames);
+                }
+
+                public void remove()
+                {
+                    throw new UnsupportedOperationException();
+                }
+            };
+
+        }
+
+        /*
+         * (non-Javadoc)
+         * @see java.util.AbstractCollection#size()
+         */
+        @Override
+        public int size()
+        {
+            return size;
+        }
+
+    }
+
 }
diff --git a/source/java/org/alfresco/repo/security/sync/NodeDescription.java b/source/java/org/alfresco/repo/security/sync/NodeDescription.java
index a36b2608f5..29657b7b0b 100644
--- a/source/java/org/alfresco/repo/security/sync/NodeDescription.java
+++ b/source/java/org/alfresco/repo/security/sync/NodeDescription.java
@@ -37,6 +37,11 @@ import org.alfresco.util.PropertyMap;
  */
 public class NodeDescription
 {
+    /**
+     * An identifier for the node for monitoring purposes. Should help trace where the node originated from.
+     */
+    private String sourceId;
+
     /** The properties. */
     private final PropertyMap properties = new PropertyMap(19);
 
@@ -46,6 +51,27 @@ public class NodeDescription
     /** The last modification date. */
     private Date lastModified;
 
+    /**
+     * Instantiates a new node description.
+     * 
+     * @param sourceId
+     *            An identifier for the node for monitoring purposes. Should help trace where the node originated from.
+     */
+    public NodeDescription(String sourceId)
+    {
+        this.sourceId = sourceId;
+    }        
+
+    /**
+     * Gets an identifier for the node for monitoring purposes. Should help trace where the node originated from.
+     * 
+     * @return an identifier for the node for monitoring purposes
+     */
+    public String getSourceId()
+    {
+        return sourceId;
+    }
+
     /**
      * Gets the last modification date.
      * 
diff --git a/source/java/org/alfresco/repo/security/sync/UserRegistry.java b/source/java/org/alfresco/repo/security/sync/UserRegistry.java
index c5bb0a358a..f45fb649c9 100644
--- a/source/java/org/alfresco/repo/security/sync/UserRegistry.java
+++ b/source/java/org/alfresco/repo/security/sync/UserRegistry.java
@@ -24,8 +24,9 @@
  */
 package org.alfresco.repo.security.sync;
 
+import java.util.Collection;
 import java.util.Date;
-import java.util.Iterator;
+import java.util.Set;
 
 /**
  * A UserRegistry is an encapsulation of an external registry from which user and group information can be
@@ -42,21 +43,29 @@ public interface UserRegistry
      * @param modifiedSince
      *            if non-null, then only descriptions of users modified since this date should be returned; if
      *            null then descriptions of all users should be returned.
-     * @return a {@link Iterator} over {@link NodeDescription}s of all the persons (users) in the user registry or all
+     * @return a {@link Collection} of {@link NodeDescription}s of all the persons (users) in the user registry or all
      *         those changed since a certain date. The description properties should correspond to those of an Alfresco
      *         person node.
      */
-    public Iterator getPersons(Date modifiedSince);
+    public Collection getPersons(Date modifiedSince);
 
     /**
-     * Gets descriptions of all the groups in the user registry or all those changed since a certain date.
+     * Gets descriptions of all the groups in the user registry or all those changed since a certain date. Group
+     * associations should be restricted to those in the given set of known authorities. Optionally this set is 'pruned'
+     * to contain only those authorities that no longer exist in the user registry, i.e. the deletion candidates.
      * 
      * @param modifiedSince
      *            if non-null, then only descriptions of groups modified since this date should be returned; if
      *            null then descriptions of all groups should be returned.
-     * @return a {@link Iterator} over {@link NodeDescription}s of all the groups in the user registry or all those
+     * @param knownAuthorities
+     *            the current set of known authorities
+     * @param prune
+     *            should this set be 'pruned' so that it contains only those authorities that do not exist in the
+     *            registry, i.e. the deletion candidates?
+     * @return a {@link Collection} of {@link NodeDescription}s of all the groups in the user registry or all those
      *         changed since a certain date. The description properties should correspond to those of an Alfresco
      *         authority node.
      */
-    public Iterator getGroups(Date modifiedSince);
+    public Collection getGroups(Date modifiedSince, Set knownAuthorities, boolean prune);
+
 }
diff --git a/source/java/org/alfresco/repo/security/sync/ldap/LDAPNameResolver.java b/source/java/org/alfresco/repo/security/sync/ldap/LDAPNameResolver.java
new file mode 100644
index 0000000000..113fc35fcd
--- /dev/null
+++ b/source/java/org/alfresco/repo/security/sync/ldap/LDAPNameResolver.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) 2005-2009 Alfresco Software Limited.
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * as published by the Free Software Foundation; either version 2
+ * of the License, or (at your option) any later version.
+
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+ * As a special exception to the terms and conditions of version 2.0 of 
+ * the GPL, you may redistribute this Program in connection with Free/Libre 
+ * and Open Source Software ("FLOSS") applications as described in Alfresco's 
+ * FLOSS exception.  You should have received a copy of the text describing 
+ * the FLOSS exception, and it is also available here: 
+ * http://www.alfresco.com/legal/licensing"
+ */
+package org.alfresco.repo.security.sync.ldap;
+
+import org.alfresco.repo.security.authentication.AuthenticationException;
+
+/**
+ * An interface for objects capable of resolving user IDs to full LDAP Distinguished Names (DNs).
+ * 
+ * @author dward
+ */
+public interface LDAPNameResolver
+{
+    
+    /**
+     * Resolves a user ID to a distinguished name.
+     * 
+     * @param userId
+     *            the user id
+     * @return the DN
+     * @throws AuthenticationException
+     *             if the user ID cannot be resolved
+     */
+    public String resolveDistinguishedName(String userId) throws AuthenticationException;
+}
diff --git a/source/java/org/alfresco/repo/security/sync/ldap/LDAPUserRegistry.java b/source/java/org/alfresco/repo/security/sync/ldap/LDAPUserRegistry.java
index 31da6e1090..86e4180c31 100644
--- a/source/java/org/alfresco/repo/security/sync/ldap/LDAPUserRegistry.java
+++ b/source/java/org/alfresco/repo/security/sync/ldap/LDAPUserRegistry.java
@@ -25,11 +25,15 @@
 package org.alfresco.repo.security.sync.ldap;
 
 import java.text.DateFormat;
+import java.text.MessageFormat;
 import java.text.ParseException;
 import java.text.SimpleDateFormat;
+import java.util.AbstractCollection;
+import java.util.Collection;
 import java.util.Date;
 import java.util.HashSet;
 import java.util.Iterator;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Set;
 import java.util.TimeZone;
@@ -41,6 +45,7 @@ import javax.naming.NamingEnumeration;
 import javax.naming.NamingException;
 import javax.naming.directory.Attribute;
 import javax.naming.directory.Attributes;
+import javax.naming.directory.DirContext;
 import javax.naming.directory.InitialDirContext;
 import javax.naming.directory.SearchControls;
 import javax.naming.directory.SearchResult;
@@ -49,6 +54,7 @@ import javax.naming.ldap.LdapName;
 import org.alfresco.error.AlfrescoRuntimeException;
 import org.alfresco.model.ContentModel;
 import org.alfresco.repo.management.subsystems.ActivateableBean;
+import org.alfresco.repo.security.authentication.AuthenticationException;
 import org.alfresco.repo.security.authentication.ldap.LDAPInitialDirContextFactory;
 import org.alfresco.repo.security.sync.NodeDescription;
 import org.alfresco.repo.security.sync.UserRegistry;
@@ -65,14 +71,18 @@ import org.springframework.beans.factory.InitializingBean;
  * 
  * @author dward
  */
-public class LDAPUserRegistry implements UserRegistry, InitializingBean, ActivateableBean
+public class LDAPUserRegistry implements UserRegistry, LDAPNameResolver, InitializingBean, ActivateableBean
 {
+
     /** The logger. */
     private static Log logger = LogFactory.getLog(LDAPUserRegistry.class);
 
     /** Is this bean active? I.e. should this part of the subsystem be used? */
     private boolean active = true;
 
+    /** Enable progress estimation? When enabled, the user query has to be run twice in order to count entries. */
+    private boolean enableProgressEstimation = true;
+
     /** The group query. */
     private String groupQuery = "(objectclass=groupOfNames)";
 
@@ -127,27 +137,30 @@ public class LDAPUserRegistry implements UserRegistry, InitializingBean, Activat
      */
     private int queryBatchSize;
 
-    /** Should we error on missing group members? */
+    /** Should we error on missing group members?. */
     private boolean errorOnMissingMembers;
 
-    /** Should we error on duplicate group IDs? */
+    /** Should we error on duplicate group IDs?. */
     private boolean errorOnDuplicateGID;
 
-    /** Should we error on missing group IDs? */
+    /** Should we error on missing group IDs?. */
     private boolean errorOnMissingGID = false;
 
-    /** Should we error on missing user IDs? */
+    /** Should we error on missing user IDs?. */
     private boolean errorOnMissingUID = false;
 
-    /** An array of all LDAP attributes to be queried from users */
+    /** An array of all LDAP attributes to be queried from users. */
     private String[] userAttributeNames;
 
-    /** An array of all LDAP attributes to be queried from groups */
+    /** An array of all LDAP attributes to be queried from groups. */
     private String[] groupAttributeNames;
 
     /** The LDAP generalized time format. */
     private DateFormat timestampFormat;
 
+    /**
+     * Instantiates a new lDAP user registry.
+     */
     public LDAPUserRegistry()
     {
         // Default to official LDAP generalized time format (unfortunately not used by Active Directory)
@@ -155,7 +168,7 @@ public class LDAPUserRegistry implements UserRegistry, InitializingBean, Activat
     }
 
     /**
-     * Indicates whether this bean is active. I.e. should this part of the subsystem be used?
+     * Controls whether this bean is active. I.e. should this part of the subsystem be used?
      * 
      * @param active
      *            true if this bean is active
@@ -165,6 +178,18 @@ public class LDAPUserRegistry implements UserRegistry, InitializingBean, Activat
         this.active = active;
     }
 
+    /**
+     * Controls whether progress estimation is enabled. When enabled, the user query has to be run twice in order to
+     * count entries.
+     * 
+     * @param enableProgressEstimation
+     *            true if progress estimation is enabled
+     */
+    public void setEnableProgressEstimation(boolean enableProgressEstimation)
+    {
+        this.enableProgressEstimation = enableProgressEstimation;
+    }
+
     /**
      * Sets the group id attribute name.
      * 
@@ -309,7 +334,7 @@ public class LDAPUserRegistry implements UserRegistry, InitializingBean, Activat
      */
     public void setTimestampFormat(String timestampFormat)
     {
-        this.timestampFormat = new SimpleDateFormat(timestampFormat);
+        this.timestampFormat = new SimpleDateFormat(timestampFormat, Locale.UK);
         this.timestampFormat.setTimeZone(TimeZone.getTimeZone("GMT"));
     }
 
@@ -450,221 +475,373 @@ public class LDAPUserRegistry implements UserRegistry, InitializingBean, Activat
      * (non-Javadoc)
      * @see org.alfresco.repo.security.sync.UserRegistry#getPersons(java.util.Date)
      */
-    public Iterator getPersons(Date modifiedSince)
+    public Collection getPersons(Date modifiedSince)
     {
-        return new PersonIterator(modifiedSince);
+        return new PersonCollection(modifiedSince);
+    }
+
+    /**
+     * Retrieves the complete set of known users and groups from the LDAP directory and removes them from the set of
+     * candidate local authorities to be deleted.
+     * 
+     * @param candidateAuthoritiesForDeletion
+     *            the candidate authorities for deletion
+     */
+    private void processDeletions(final Set candidateAuthoritiesForDeletion)
+    {
+        processQuery(new SearchCallback()
+        {
+            public void process(SearchResult result) throws NamingException, ParseException
+            {
+                Attribute nameAttribute = result.getAttributes().get(LDAPUserRegistry.this.userIdAttributeName);
+                if (nameAttribute == null)
+                {
+                    if (LDAPUserRegistry.this.errorOnMissingUID)
+                    {
+                        throw new AlfrescoRuntimeException("User missing user id attribute DN ="
+                                + result.getNameInNamespace() + "  att = " + LDAPUserRegistry.this.userIdAttributeName);
+                    }
+                    else
+                    {
+                        LDAPUserRegistry.logger.warn("User missing user id attribute DN ="
+                                + result.getNameInNamespace() + "  att = " + LDAPUserRegistry.this.userIdAttributeName);
+                    }
+                }
+                else
+                {
+                    String authority = (String) nameAttribute.get();
+                    candidateAuthoritiesForDeletion.remove(authority);
+                }
+            }
+
+            public void close() throws NamingException
+            {
+            }
+
+        }, this.userSearchBase, this.personQuery, new String[]
+        {
+            this.userIdAttributeName
+        });
+        processQuery(new SearchCallback()
+        {
+
+            public void process(SearchResult result) throws NamingException, ParseException
+            {
+                Attribute nameAttribute = result.getAttributes().get(LDAPUserRegistry.this.groupIdAttributeName);
+                if (nameAttribute == null)
+                {
+                    if (LDAPUserRegistry.this.errorOnMissingGID)
+                    {
+                        throw new AlfrescoRuntimeException(
+                                "NodeDescription returned by group search does not have mandatory group id attribute "
+                                        + result.getNameInNamespace());
+                    }
+                    else
+                    {
+                        LDAPUserRegistry.logger.warn("Missing GID on " + result.getNameInNamespace());
+                    }
+                }
+                else
+                {
+                    String authority = "GROUP_" + (String) nameAttribute.get();
+                    candidateAuthoritiesForDeletion.remove(authority);
+                }
+            }
+
+            public void close() throws NamingException
+            {
+            }
+
+        }, this.groupSearchBase, this.groupQuery, new String[]
+        {
+            this.groupIdAttributeName
+        });
     }
 
     /*
      * (non-Javadoc)
      * @see org.alfresco.repo.security.sync.UserRegistry#getGroups(java.util.Date)
      */
-    public Iterator getGroups(Date modifiedSince)
+    public Collection getGroups(Date modifiedSince, final Set candidateAuthoritiesForDeletion,
+            boolean prune)
     {
-        Map lookup = new TreeMap();
-        SearchControls userSearchCtls = new SearchControls();
-        userSearchCtls.setSearchScope(SearchControls.SUBTREE_SCOPE);
-        userSearchCtls.setReturningAttributes(this.groupAttributeNames);
+        // Take the given set of authorities as a starting point for the set of all authorities
+        final Set allAuthorities = new TreeSet(candidateAuthoritiesForDeletion);
 
-        InitialDirContext ctx = null;
+        // If required, work out what authority deletions are required by pruning down the deletion set and the set of
+        // all authorities
+        if (prune)
+        {
+            processDeletions(candidateAuthoritiesForDeletion);
+            allAuthorities.removeAll(candidateAuthoritiesForDeletion);
+        }
+
+        // Work out whether the user and group trees are disjoint. This may allow us to optimize reverse DN
+        // resolution.
+        final LdapName groupDistinguishedNamePrefix;
+        final LdapName userDistinguishedNamePrefix;
         try
         {
-            ctx = this.ldapInitialContextFactory.getDefaultIntialDirContext(this.queryBatchSize);
+            groupDistinguishedNamePrefix = new LdapName(this.groupSearchBase.toLowerCase());
+            userDistinguishedNamePrefix = new LdapName(this.userSearchBase.toLowerCase());
+        }
+        catch (InvalidNameException e)
+        {
+            throw new AlfrescoRuntimeException("User and group import failed", e);
+        }
+        final boolean disjoint = !groupDistinguishedNamePrefix.startsWith(userDistinguishedNamePrefix)
+                && !userDistinguishedNamePrefix.startsWith(groupDistinguishedNamePrefix);
 
-            LdapName groupDistinguishedNamePrefix = new LdapName(this.groupSearchBase);
-            LdapName userDistinguishedNamePrefix = new LdapName(this.userSearchBase);
+        // Choose / generate the query
+        String query;
+        if (modifiedSince == null)
+        {
+            query = this.groupQuery;
+        }
+        else
+        {
+            query = MessageFormat.format(this.groupDifferentialQuery, this.timestampFormat.format(modifiedSince));
+        }
 
-            // Work out whether the user and group trees are disjoint. This may allow us to optimize reverse DN
-            // resolution.
-            boolean disjoint = !groupDistinguishedNamePrefix.startsWith(userDistinguishedNamePrefix)
-                    && !userDistinguishedNamePrefix.startsWith(groupDistinguishedNamePrefix);
+        // Run the query and process the results
+        final Map lookup = new TreeMap();
+        processQuery(new SearchCallback()
+        {
+            // We get a whole new context to avoid interference with cookies from paged results
+            private DirContext ctx = LDAPUserRegistry.this.ldapInitialContextFactory.getDefaultIntialDirContext();
 
-            do
+            public void process(SearchResult result) throws NamingException, ParseException
             {
-                NamingEnumeration searchResults;
-
-                if (modifiedSince == null)
+                Attributes attributes = result.getAttributes();
+                Attribute gidAttribute = attributes.get(LDAPUserRegistry.this.groupIdAttributeName);
+                if (gidAttribute == null)
                 {
-                    searchResults = ctx.search(this.groupSearchBase, this.groupQuery, userSearchCtls);
-                }
-                else
-                {
-                    searchResults = ctx.search(this.groupSearchBase, this.groupDifferentialQuery, new Object[]
+                    if (LDAPUserRegistry.this.errorOnMissingGID)
                     {
-                        this.timestampFormat.format(modifiedSince)
-                    }, userSearchCtls);
-                }
-
-                while (searchResults.hasMoreElements())
-                {
-                    SearchResult result = searchResults.next();
-                    Attributes attributes = result.getAttributes();
-                    Attribute gidAttribute = attributes.get(this.groupIdAttributeName);
-                    if (gidAttribute == null)
-                    {
-                        if (this.errorOnMissingGID)
-                        {
-                            throw new AlfrescoRuntimeException(
-                                    "NodeDescription returned by group search does not have mandatory group id attribute "
-                                            + attributes);
-                        }
-                        else
-                        {
-                            LDAPUserRegistry.logger.warn("Missing GID on " + attributes);
-                            continue;
-                        }
-                    }
-                    String gid = "GROUP_" + gidAttribute.get(0);
-
-                    NodeDescription group = lookup.get(gid);
-                    if (group == null)
-                    {
-                        group = new NodeDescription();
-                        group.getProperties().put(ContentModel.PROP_AUTHORITY_NAME, gid);
-                        lookup.put(gid, group);
-                    }
-                    else if (this.errorOnDuplicateGID)
-                    {
-                        throw new AlfrescoRuntimeException("Duplicate group id found for " + gid);
+                        throw new AlfrescoRuntimeException(
+                                "NodeDescription returned by group search does not have mandatory group id attribute "
+                                        + attributes);
                     }
                     else
                     {
-                        LDAPUserRegistry.logger.warn("Duplicate gid found for " + gid + " -> merging definitions");
+                        LDAPUserRegistry.logger.warn("Missing GID on " + attributes);
+                        return;
                     }
+                }
+                String gid = "GROUP_" + gidAttribute.get(0);
 
-                    Attribute modifyTimestamp = attributes.get(this.modifyTimestampAttributeName);
-                    if (modifyTimestamp != null)
-                    {
-                        group.setLastModified(this.timestampFormat.parse(modifyTimestamp.get().toString()));
-                    }
-                    Set childAssocs = group.getChildAssociations();
+                NodeDescription group = lookup.get(gid);
+                if (group == null)
+                {
+                    group = new NodeDescription(result.getNameInNamespace());
+                    group.getProperties().put(ContentModel.PROP_AUTHORITY_NAME, gid);
+                    lookup.put(gid, group);
+                    allAuthorities.add(gid);
+                }
+                else if (LDAPUserRegistry.this.errorOnDuplicateGID)
+                {
+                    throw new AlfrescoRuntimeException("Duplicate group id found for " + gid);
+                }
+                else
+                {
+                    LDAPUserRegistry.logger.warn("Duplicate gid found for " + gid + " -> merging definitions");
+                }
 
-                    Attribute memAttribute = attributes.get(this.memberAttributeName);
-                    // check for null
-                    if (memAttribute != null)
+                Attribute modifyTimestamp = attributes.get(LDAPUserRegistry.this.modifyTimestampAttributeName);
+                if (modifyTimestamp != null)
+                {
+                    group
+                            .setLastModified(LDAPUserRegistry.this.timestampFormat.parse(modifyTimestamp.get()
+                                    .toString()));
+                }
+                Set childAssocs = group.getChildAssociations();
+
+                Attribute memAttribute = attributes.get(LDAPUserRegistry.this.memberAttributeName);
+                // check for null
+                if (memAttribute != null)
+                {
+                    for (int i = 0; i < memAttribute.size(); i++)
                     {
-                        for (int i = 0; i < memAttribute.size(); i++)
+                        String attribute = (String) memAttribute.get(i);
+                        if (attribute != null && attribute.length() > 0)
                         {
-                            String attribute = (String) memAttribute.get(i);
-                            if (attribute != null && attribute.length() > 0)
+                            try
                             {
-                                try
+                                // Attempt to parse the member attribute as a DN. If this fails we have a fallback
+                                // in the catch block
+                                LdapName distinguishedName = new LdapName(attribute.toLowerCase());
+                                Attribute nameAttribute;
+
+                                // If the user and group search bases are different we may be able to recognize user
+                                // and group DNs without a secondary lookup
+                                if (disjoint)
                                 {
-                                    // Attempt to parse the member attribute as a DN. If this fails we have a fallback
-                                    // in the catch block
-                                    LdapName distinguishedName = new LdapName(attribute);
-                                    Attribute nameAttribute;
+                                    Attributes nameAttributes = distinguishedName.getRdn(distinguishedName.size() - 1)
+                                            .toAttributes();
 
-                                    // If the user and group search bases are different we may be able to recognize user
-                                    // and group DNs without a secondary lookup
-                                    if (disjoint)
+                                    // Recognize user DNs
+                                    if (distinguishedName.startsWith(userDistinguishedNamePrefix)
+                                            && (nameAttribute = nameAttributes
+                                                    .get(LDAPUserRegistry.this.userIdAttributeName)) != null)
                                     {
-                                        Attributes nameAttributes = distinguishedName.getRdn(
-                                                distinguishedName.size() - 1).toAttributes();
+                                        childAssocs.add((String) nameAttribute.get());
+                                        continue;
+                                    }
 
-                                        // Recognize user DNs
-                                        if (distinguishedName.startsWith(userDistinguishedNamePrefix)
-                                                && (nameAttribute = nameAttributes.get(this.userIdAttributeName)) != null)
+                                    // Recognize group DNs
+                                    if (distinguishedName.startsWith(groupDistinguishedNamePrefix)
+                                            && (nameAttribute = nameAttributes
+                                                    .get(LDAPUserRegistry.this.groupIdAttributeName)) != null)
+                                    {
+                                        childAssocs.add("GROUP_" + nameAttribute.get());
+                                        continue;
+                                    }
+                                }
+
+                                // If we can't determine the name and type from the DN alone, try a directory lookup
+                                if (distinguishedName.startsWith(userDistinguishedNamePrefix)
+                                        || distinguishedName.startsWith(groupDistinguishedNamePrefix))
+                                {
+                                    try
+                                    {
+                                        Attributes childAttributes = this.ctx.getAttributes(attribute, new String[]
                                         {
+                                            "objectclass", LDAPUserRegistry.this.groupIdAttributeName,
+                                            LDAPUserRegistry.this.userIdAttributeName
+                                        });
+                                        Attribute objectClass = childAttributes.get("objectclass");
+                                        if (hasAttributeValue(objectClass, LDAPUserRegistry.this.personType))
+                                        {
+                                            nameAttribute = childAttributes
+                                                    .get(LDAPUserRegistry.this.userIdAttributeName);
+                                            if (nameAttribute == null)
+                                            {
+                                                if (LDAPUserRegistry.this.errorOnMissingUID)
+                                                {
+                                                    throw new AlfrescoRuntimeException(
+                                                            "User missing user id attribute DN =" + attribute
+                                                                    + "  att = "
+                                                                    + LDAPUserRegistry.this.userIdAttributeName);
+                                                }
+                                                else
+                                                {
+                                                    LDAPUserRegistry.logger.warn("User missing user id attribute DN ="
+                                                            + attribute + "  att = "
+                                                            + LDAPUserRegistry.this.userIdAttributeName);
+                                                    continue;
+                                                }
+                                            }
+
                                             childAssocs.add((String) nameAttribute.get());
                                             continue;
                                         }
-
-                                        // Recognize group DNs
-                                        if (distinguishedName.startsWith(groupDistinguishedNamePrefix)
-                                                && (nameAttribute = nameAttributes.get(this.groupIdAttributeName)) != null)
+                                        else if (hasAttributeValue(objectClass, LDAPUserRegistry.this.groupType))
                                         {
+                                            nameAttribute = childAttributes
+                                                    .get(LDAPUserRegistry.this.groupIdAttributeName);
+                                            if (nameAttribute == null)
+                                            {
+                                                if (LDAPUserRegistry.this.errorOnMissingGID)
+                                                {
+                                                    throw new AlfrescoRuntimeException(
+                                                            "Group returned by group search does not have mandatory group id attribute "
+                                                                    + attributes);
+                                                }
+                                                else
+                                                {
+                                                    LDAPUserRegistry.logger.warn("Missing GID on " + childAttributes);
+                                                    continue;
+                                                }
+                                            }
                                             childAssocs.add("GROUP_" + nameAttribute.get());
                                             continue;
                                         }
                                     }
-
-                                    // If we can't determine the name and type from the DN alone, try a directory lookup
-                                    if (distinguishedName.startsWith(userDistinguishedNamePrefix)
-                                            || distinguishedName.startsWith(groupDistinguishedNamePrefix))
+                                    catch (NamingException e)
                                     {
-                                        try
+                                        // Unresolvable name
+                                        if (LDAPUserRegistry.this.errorOnMissingMembers)
                                         {
-                                            Attributes childAttributes = ctx.getAttributes(attribute, new String[]
-                                            {
-                                                "objectclass", this.groupIdAttributeName, this.userIdAttributeName
-                                            });
-                                            Attribute objectClass = childAttributes.get("objectclass");
-                                            if (hasAttributeValue(objectClass, this.personType))
-                                            {
-                                                nameAttribute = childAttributes.get(this.userIdAttributeName);
-                                                if (nameAttribute == null)
-                                                {
-                                                    if (this.errorOnMissingUID)
-                                                    {
-                                                        throw new AlfrescoRuntimeException(
-                                                                "User missing user id attribute DN =" + attribute
-                                                                        + "  att = " + this.userIdAttributeName);
-                                                    }
-                                                    else
-                                                    {
-                                                        LDAPUserRegistry.logger
-                                                                .warn("User missing user id attribute DN =" + attribute
-                                                                        + "  att = " + this.userIdAttributeName);
-                                                        continue;
-                                                    }
-                                                }
-
-                                                childAssocs.add((String) nameAttribute.get());
-                                                continue;
-                                            }
-                                            else if (hasAttributeValue(objectClass, this.groupType))
-                                            {
-                                                nameAttribute = childAttributes.get(this.groupIdAttributeName);
-                                                if (nameAttribute == null)
-                                                {
-                                                    if (this.errorOnMissingGID)
-                                                    {
-                                                        throw new AlfrescoRuntimeException(
-                                                                "Group returned by group search does not have mandatory group id attribute "
-                                                                        + attributes);
-                                                    }
-                                                    else
-                                                    {
-                                                        LDAPUserRegistry.logger.warn("Missing GID on "
-                                                                + childAttributes);
-                                                        continue;
-                                                    }
-                                                }
-                                                childAssocs.add("GROUP_" + nameAttribute.get());
-                                                continue;
-                                            }
-                                        }
-                                        catch (NamingException e)
-                                        {
-                                            // Unresolvable name
+                                            throw new AlfrescoRuntimeException("Failed to resolve distinguished name: "
+                                                    + attribute, e);
                                         }
+                                        LDAPUserRegistry.logger.warn("Failed to resolve distinguished name: "
+                                                + attribute, e);
+                                        continue;
                                     }
-                                    if (this.errorOnMissingMembers)
-                                    {
-                                        throw new AlfrescoRuntimeException("Failed to resolve distinguished name: "
-                                                + attribute);
-                                    }
-                                    LDAPUserRegistry.logger.warn("Failed to resolve distinguished name: " + attribute);
                                 }
-                                catch (InvalidNameException e)
+                                if (LDAPUserRegistry.this.errorOnMissingMembers)
                                 {
-                                    // The member attribute didn't parse as a DN. So assume we have a group class like posixGroup (FDS) that directly lists user names
-                                    childAssocs.add(attribute);
+                                    throw new AlfrescoRuntimeException("Failed to resolve distinguished name: "
+                                            + attribute);
                                 }
+                                LDAPUserRegistry.logger.warn("Failed to resolve distinguished name: " + attribute);
+                            }
+                            catch (InvalidNameException e)
+                            {
+                                // The member attribute didn't parse as a DN. So assume we have a group class like
+                                // posixGroup (FDS) that directly lists user names
+                                childAssocs.add(attribute);
                             }
                         }
                     }
                 }
             }
-            while (this.ldapInitialContextFactory.hasNextPage(ctx, this.queryBatchSize));
 
-            if (LDAPUserRegistry.logger.isDebugEnabled())
+            public void close() throws NamingException
             {
-                LDAPUserRegistry.logger.debug("Found " + lookup.size());
+                this.ctx.close();
             }
+        }, this.groupSearchBase, query, this.groupAttributeNames);
 
-            return lookup.values().iterator();
+        if (LDAPUserRegistry.logger.isDebugEnabled())
+        {
+            LDAPUserRegistry.logger.debug("Found " + lookup.size());
+        }
+
+        // Post-process the group associations to filter out those that point to excluded users or groups (now that we
+        // know the full set of groups)
+        for (NodeDescription group : lookup.values())
+        {
+            group.getChildAssociations().retainAll(allAuthorities);
+        }
+        return lookup.values();
+    }
+
+    /**
+     * Invokes the given callback on each entry returned by the given query.
+     * 
+     * @param callback
+     *            the callback
+     * @param searchBase
+     *            the base DN for the search
+     * @param query
+     *            the query
+     * @param returningAttributes
+     *            the attributes to include in search results
+     */
+    private void processQuery(SearchCallback callback, String searchBase, String query, String[] returningAttributes)
+    {
+        SearchControls searchControls = new SearchControls();
+        searchControls.setSearchScope(SearchControls.SUBTREE_SCOPE);
+        searchControls.setReturningAttributes(returningAttributes);
+
+        InitialDirContext ctx = null;
+        try
+        {
+            ctx = this.ldapInitialContextFactory.getDefaultIntialDirContext(this.queryBatchSize);
+            do
+            {
+                NamingEnumeration searchResults;
+                searchResults = ctx.search(searchBase, query, searchControls);
+
+                while (searchResults.hasMoreElements())
+                {
+                    SearchResult result = searchResults.next();
+                    callback.process(result);
+                }
+            }
+            while (this.ldapInitialContextFactory.hasNextPage(ctx, this.queryBatchSize));
         }
         catch (NamingException e)
         {
@@ -675,6 +852,57 @@ public class LDAPUserRegistry implements UserRegistry, InitializingBean, Activat
             throw new AlfrescoRuntimeException("User and group import failed", e);
         }
         finally
+        {
+            if (ctx != null)
+            {
+                try
+                {
+                    ctx.close();
+                }
+                catch (NamingException e)
+                {
+                }
+            }
+            try
+            {
+                callback.close();
+            }
+            catch (NamingException e)
+            {
+            }
+        }
+    }
+
+    /*
+     * (non-Javadoc)
+     * @see org.alfresco.repo.security.sync.ldap.LDAPNameResolver#resolveDistinguishedName(java.lang.String)
+     */
+    public String resolveDistinguishedName(String userId) throws AuthenticationException
+    {
+        SearchControls userSearchCtls = new SearchControls();
+        userSearchCtls.setSearchScope(SearchControls.SUBTREE_SCOPE);
+        userSearchCtls.setReturningAttributes(new String[] {});
+        InitialDirContext ctx = null;
+        try
+        {
+            ctx = this.ldapInitialContextFactory.getDefaultIntialDirContext();
+
+            // Execute the user query with an additional condition that ensures only the user with the required ID is
+            // returned
+            NamingEnumeration searchResults = ctx.search(this.userSearchBase, "(&" + this.personQuery
+                    + "(" + this.userIdAttributeName + "=" + userId + "))", userSearchCtls);
+
+            if (searchResults.hasMoreElements())
+            {
+                return searchResults.next().getNameInNamespace();
+            }
+            throw new AuthenticationException("Failed to resolve user: " + userId);
+        }
+        catch (NamingException e)
+        {
+            throw new AlfrescoRuntimeException("Failed to resolve user ID: " + userId, e);
+        }
+        finally
         {
             if (ctx != null)
             {
@@ -690,7 +918,7 @@ public class LDAPUserRegistry implements UserRegistry, InitializingBean, Activat
     }
 
     /**
-     * Does a case-insensitive search for the given value in an attribute
+     * Does a case-insensitive search for the given value in an attribute.
      * 
      * @param attribute
      *            the attribute
@@ -721,185 +949,275 @@ public class LDAPUserRegistry implements UserRegistry, InitializingBean, Activat
     }
 
     /**
-     * Wraps the LDAP user query as an {@link Iterator}.
+     * Wraps the LDAP user query as a virtual {@link Collection}.
      */
-    public class PersonIterator implements Iterator
+    public class PersonCollection extends AbstractCollection
     {
 
-        /** The directory context. */
-        private InitialDirContext ctx;
+        /** The query. */
+        private String query;
 
-        private SearchControls userSearchCtls;
-
-        private Date modifiedSince;
-
-        /** The search results. */
-        private NamingEnumeration searchResults;
-
-        /** The uids. */
-        private HashSet uids = new HashSet();
-
-        /** The next node description to return. */
-        private NodeDescription next;
+        /** The total estimated size. */
+        private int totalEstimatedSize;
 
         /**
-         * Instantiates a new person iterator.
+         * Instantiates a new person collection.
          * 
          * @param modifiedSince
          *            if non-null, then only descriptions of users modified since this date should be returned; if
          *            null then descriptions of all users should be returned.
          */
-        public PersonIterator(Date modifiedSince)
+        public PersonCollection(Date modifiedSince)
         {
-            try
+            // Choose / generate the appropriate query
+            if (modifiedSince == null)
             {
-                this.ctx = LDAPUserRegistry.this.ldapInitialContextFactory
-                        .getDefaultIntialDirContext(LDAPUserRegistry.this.queryBatchSize);
-
-                // Authentication has been successful.
-                // Set the current user, they are now authenticated.
-
-                this.userSearchCtls = new SearchControls();
-                this.userSearchCtls.setSearchScope(SearchControls.SUBTREE_SCOPE);
-                this.userSearchCtls.setReturningAttributes(LDAPUserRegistry.this.userAttributeNames);
-
-                this.modifiedSince = modifiedSince;
-
-                this.next = fetchNext();
+                this.query = LDAPUserRegistry.this.personQuery;
             }
-            catch (NamingException e)
+            else
             {
-                throw new AlfrescoRuntimeException("Failed to import people.", e);
+                this.query = MessageFormat.format(LDAPUserRegistry.this.personDifferentialQuery,
+                        LDAPUserRegistry.this.timestampFormat.format(modifiedSince));
             }
-            finally
+
+            // Estimate the size of this collection by running the entire query once, if progress
+            // estimation is enabled
+            if (LDAPUserRegistry.this.enableProgressEstimation)
             {
-                if (this.searchResults == null)
+                class CountingCallback implements SearchCallback
                 {
-                    try
+                    int count;
+
+                    /*
+                     * (non-Javadoc)
+                     * @see
+                     * org.alfresco.repo.security.sync.ldap.LDAPUserRegistry.SearchCallback#process(javax.naming.directory
+                     * .SearchResult)
+                     */
+                    public void process(SearchResult result) throws NamingException, ParseException
                     {
-                        this.ctx.close();
+                        this.count++;
                     }
-                    catch (Exception e)
+
+                    /*
+                     * (non-Javadoc)
+                     * @see org.alfresco.repo.security.sync.ldap.LDAPUserRegistry.SearchCallback#close()
+                     */
+                    public void close() throws NamingException
                     {
                     }
-                    this.ctx = null;
+
                 }
+                CountingCallback countingCallback = new CountingCallback();
+                processQuery(countingCallback, LDAPUserRegistry.this.userSearchBase, this.query, new String[] {});
+                this.totalEstimatedSize = countingCallback.count;
+            }
+            else
+            {
+                this.totalEstimatedSize = -1;
             }
         }
 
         /*
          * (non-Javadoc)
-         * @see java.util.Iterator#hasNext()
+         * @see java.util.AbstractCollection#iterator()
          */
-        public boolean hasNext()
+        @Override
+        public Iterator iterator()
         {
-            return this.next != null;
+            return new PersonIterator();
         }
 
         /*
          * (non-Javadoc)
-         * @see java.util.Iterator#next()
+         * @see java.util.AbstractCollection#size()
          */
-        public NodeDescription next()
+        @Override
+        public int size()
         {
-            if (this.next == null)
-            {
-                throw new IllegalStateException();
-            }
-            NodeDescription current = this.next;
-            try
-            {
-                this.next = fetchNext();
-            }
-            catch (NamingException e)
-            {
-                throw new AlfrescoRuntimeException("Failed to import people.", e);
-            }
-            return current;
+            return this.totalEstimatedSize;
         }
 
         /**
-         * Pre-fetches the next node description to be returned.
-         * 
-         * @return the node description
-         * @throws NamingException
-         *             on a naming exception
+         * An iterator over the person collection. Wraps the LDAP query in 'real time'.
          */
-        private NodeDescription fetchNext() throws NamingException
+        private class PersonIterator implements Iterator
         {
-            boolean readyForNextPage;
-            do
+
+            /** The directory context. */
+            private InitialDirContext ctx;
+
+            /** The user search controls. */
+            private SearchControls userSearchCtls;
+
+            /** The search results. */
+            private NamingEnumeration searchResults;
+
+            /** The uids. */
+            private HashSet uids = new HashSet();
+
+            /** The next node description to return. */
+            private NodeDescription next;
+
+            /**
+             * Instantiates a new person iterator.
+             */
+            public PersonIterator()
             {
-                readyForNextPage = this.searchResults == null;
-                while (!readyForNextPage && this.searchResults.hasMoreElements())
+                try
                 {
-                    SearchResult result = this.searchResults.next();
-                    Attributes attributes = result.getAttributes();
-                    Attribute uidAttribute = attributes.get(LDAPUserRegistry.this.userIdAttributeName);
-                    if (uidAttribute == null)
-                    {
-                        if (LDAPUserRegistry.this.errorOnMissingUID)
-                        {
-                            throw new AlfrescoRuntimeException(
-                                    "User returned by user search does not have mandatory user id attribute "
-                                            + attributes);
-                        }
-                        else
-                        {
-                            LDAPUserRegistry.logger
-                                    .warn("User returned by user search does not have mandatory user id attribute "
-                                            + attributes);
-                            continue;
-                        }
-                    }
-                    String uid = (String) uidAttribute.get(0);
 
-                    if (this.uids.contains(uid))
-                    {
-                        LDAPUserRegistry.logger
-                                .warn("Duplicate uid found - there will be more than one person object for this user - "
-                                        + uid);
-                    }
+                    this.ctx = LDAPUserRegistry.this.ldapInitialContextFactory
+                            .getDefaultIntialDirContext(LDAPUserRegistry.this.queryBatchSize);
 
-                    this.uids.add(uid);
+                    this.userSearchCtls = new SearchControls();
+                    this.userSearchCtls.setSearchScope(SearchControls.SUBTREE_SCOPE);
+                    this.userSearchCtls.setReturningAttributes(LDAPUserRegistry.this.userAttributeNames);
 
-                    if (LDAPUserRegistry.logger.isDebugEnabled())
-                    {
-                        LDAPUserRegistry.logger.debug("Adding user for " + uid);
-                    }
-
-                    NodeDescription person = new NodeDescription();
-
-                    Attribute modifyTimestamp = attributes.get(LDAPUserRegistry.this.modifyTimestampAttributeName);
-                    if (modifyTimestamp != null)
+                    this.next = fetchNext();
+                }
+                catch (NamingException e)
+                {
+                    throw new AlfrescoRuntimeException("Failed to import people.", e);
+                }
+                finally
+                {
+                    if (this.searchResults == null)
                     {
                         try
                         {
-                            person.setLastModified(LDAPUserRegistry.this.timestampFormat.parse(modifyTimestamp.get()
-                                    .toString()));
+                            this.ctx.close();
                         }
-                        catch (ParseException e)
+                        catch (Exception e)
                         {
-                            throw new AlfrescoRuntimeException("Failed to import people.", e);
                         }
+                        this.ctx = null;
                     }
+                }
+            }
 
-                    PropertyMap properties = person.getProperties();
-                    for (String key : LDAPUserRegistry.this.attributeMapping.keySet())
+            /*
+             * (non-Javadoc)
+             * @see java.util.Iterator#hasNext()
+             */
+            public boolean hasNext()
+            {
+                return this.next != null;
+            }
+
+            /*
+             * (non-Javadoc)
+             * @see java.util.Iterator#next()
+             */
+            public NodeDescription next()
+            {
+                if (this.next == null)
+                {
+                    throw new IllegalStateException();
+                }
+                NodeDescription current = this.next;
+                try
+                {
+                    this.next = fetchNext();
+                }
+                catch (NamingException e)
+                {
+                    throw new AlfrescoRuntimeException("Failed to import people.", e);
+                }
+                return current;
+            }
+
+            /**
+             * Pre-fetches the next node description to be returned.
+             * 
+             * @return the node description
+             * @throws NamingException
+             *             on a naming exception
+             */
+            private NodeDescription fetchNext() throws NamingException
+            {
+                boolean readyForNextPage;
+                do
+                {
+                    readyForNextPage = this.searchResults == null;
+                    while (!readyForNextPage && this.searchResults.hasMoreElements())
                     {
-                        QName keyQName = QName.createQName(key, LDAPUserRegistry.this.namespaceService);
-
-                        // cater for null
-                        String attributeName = LDAPUserRegistry.this.attributeMapping.get(key);
-                        if (attributeName != null)
+                        SearchResult result = this.searchResults.next();
+                        Attributes attributes = result.getAttributes();
+                        Attribute uidAttribute = attributes.get(LDAPUserRegistry.this.userIdAttributeName);
+                        if (uidAttribute == null)
                         {
-                            Attribute attribute = attributes.get(attributeName);
-                            if (attribute != null)
+                            if (LDAPUserRegistry.this.errorOnMissingUID)
                             {
-                                String value = (String) attribute.get(0);
-                                if (value != null)
+                                throw new AlfrescoRuntimeException(
+                                        "User returned by user search does not have mandatory user id attribute "
+                                                + attributes);
+                            }
+                            else
+                            {
+                                LDAPUserRegistry.logger
+                                        .warn("User returned by user search does not have mandatory user id attribute "
+                                                + attributes);
+                                continue;
+                            }
+                        }
+                        String uid = (String) uidAttribute.get(0);
+
+                        if (this.uids.contains(uid))
+                        {
+                            LDAPUserRegistry.logger
+                                    .warn("Duplicate uid found - there will be more than one person object for this user - "
+                                            + uid);
+                        }
+
+                        this.uids.add(uid);
+
+                        if (LDAPUserRegistry.logger.isDebugEnabled())
+                        {
+                            LDAPUserRegistry.logger.debug("Adding user for " + uid);
+                        }
+
+                        NodeDescription person = new NodeDescription(result.getNameInNamespace());
+
+                        Attribute modifyTimestamp = attributes.get(LDAPUserRegistry.this.modifyTimestampAttributeName);
+                        if (modifyTimestamp != null)
+                        {
+                            try
+                            {
+                                person.setLastModified(LDAPUserRegistry.this.timestampFormat.parse(modifyTimestamp
+                                        .get().toString()));
+                            }
+                            catch (ParseException e)
+                            {
+                                throw new AlfrescoRuntimeException("Failed to import people.", e);
+                            }
+                        }
+
+                        PropertyMap properties = person.getProperties();
+                        for (String key : LDAPUserRegistry.this.attributeMapping.keySet())
+                        {
+                            QName keyQName = QName.createQName(key, LDAPUserRegistry.this.namespaceService);
+
+                            // cater for null
+                            String attributeName = LDAPUserRegistry.this.attributeMapping.get(key);
+                            if (attributeName != null)
+                            {
+                                Attribute attribute = attributes.get(attributeName);
+                                if (attribute != null)
                                 {
-                                    properties.put(keyQName, value);
+                                    String value = (String) attribute.get(0);
+                                    if (value != null)
+                                    {
+                                        properties.put(keyQName, value);
+                                    }
+                                }
+                                else
+                                {
+                                    String defaultValue = LDAPUserRegistry.this.attributeDefaults.get(key);
+                                    if (defaultValue != null)
+                                    {
+                                        properties.put(keyQName, defaultValue);
+                                    }
                                 }
                             }
                             else
@@ -911,58 +1229,68 @@ public class LDAPUserRegistry implements UserRegistry, InitializingBean, Activat
                                 }
                             }
                         }
-                        else
-                        {
-                            String defaultValue = LDAPUserRegistry.this.attributeDefaults.get(key);
-                            if (defaultValue != null)
-                            {
-                                properties.put(keyQName, defaultValue);
-                            }
-                        }
+                        return person;
                     }
-                    return person;
-                }
 
-                // Examine the paged results control response for an indication that another page is available
-                if (!readyForNextPage)
-                {
-                    readyForNextPage = LDAPUserRegistry.this.ldapInitialContextFactory.hasNextPage(this.ctx,
-                            LDAPUserRegistry.this.queryBatchSize);
-                }
+                    // Examine the paged results control response for an indication that another page is available
+                    if (!readyForNextPage)
+                    {
+                        readyForNextPage = LDAPUserRegistry.this.ldapInitialContextFactory.hasNextPage(this.ctx,
+                                LDAPUserRegistry.this.queryBatchSize);
+                    }
 
-                // Fetch the next page if there is one
-                if (readyForNextPage)
-                {
-                    if (this.modifiedSince == null)
+                    // Fetch the next page if there is one
+                    if (readyForNextPage)
                     {
                         this.searchResults = this.ctx.search(LDAPUserRegistry.this.userSearchBase,
-                                LDAPUserRegistry.this.personQuery, this.userSearchCtls);
-                    }
-                    else
-                    {
-                        this.searchResults = this.ctx.search(LDAPUserRegistry.this.userSearchBase,
-                                LDAPUserRegistry.this.personDifferentialQuery, new Object[]
-                                {
-                                    LDAPUserRegistry.this.timestampFormat.format(this.modifiedSince)
-                                }, this.userSearchCtls);
+                                PersonCollection.this.query, this.userSearchCtls);
                     }
                 }
+                while (readyForNextPage);
+                this.searchResults.close();
+                this.searchResults = null;
+                this.ctx.close();
+                this.ctx = null;
+                return null;
             }
-            while (readyForNextPage);
-            this.searchResults.close();
-            this.searchResults = null;
-            this.ctx.close();
-            this.ctx = null;
-            return null;
-        }
 
-        /*
-         * (non-Javadoc)
-         * @see java.util.Iterator#remove()
-         */
-        public void remove()
-        {
-            throw new UnsupportedOperationException();
-        }
+            /*
+             * (non-Javadoc)
+             * @see java.util.Iterator#remove()
+             */
+            public void remove()
+            {
+                throw new UnsupportedOperationException();
+            }
+        };
     }
+
+    /**
+     * An interface for callbacks passed to the
+     * {@link LDAPUserRegistry#processQuery(SearchCallback, String, String, String[])} method.
+     */
+    protected static interface SearchCallback
+    {
+
+        /**
+         * Processes the given search result.
+         * 
+         * @param result
+         *            the result
+         * @throws NamingException
+         *             on naming exceptions
+         * @throws ParseException
+         *             on parse exceptions
+         */
+        public void process(SearchResult result) throws NamingException, ParseException;
+
+        /**
+         * Release any resources held by the callback.
+         * 
+         * @throws NamingException
+         *             the naming exception
+         */
+        public void close() throws NamingException;
+    }
+
 }
diff --git a/source/java/org/alfresco/repo/template/People.java b/source/java/org/alfresco/repo/template/People.java
index d77574a0ad..19684d515e 100644
--- a/source/java/org/alfresco/repo/template/People.java
+++ b/source/java/org/alfresco/repo/template/People.java
@@ -227,6 +227,19 @@ public class People extends BaseTemplateProcessorExtension
         return this.authorityService.isAdminAuthority((String)person.getProperties().get(ContentModel.PROP_USERNAME));
     }
     
+    /**
+     * Return true if the specified user is an Administrator authority.
+     * 
+     * @param person to test
+     * 
+     * @return true if an admin, false otherwise
+     */
+    public boolean isGuest(TemplateNode person)
+    {
+        ParameterCheck.mandatory("Person", person);
+        return this.authorityService.isGuestAuthority((String)person.getProperties().get(ContentModel.PROP_USERNAME));
+    }
+
     /**
      * Return true if the specified user account is enabled.
      *  
diff --git a/source/test-resources/sync-test-context.xml b/source/test-resources/sync-test-context.xml
index 6658891f94..b5595d2747 100644
--- a/source/test-resources/sync-test-context.xml
+++ b/source/test-resources/sync-test-context.xml
@@ -24,6 +24,9 @@
         
             userRegistry
         
+        
+            10
+