You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@directory.apache.org by co...@apache.org on 2026/10/14 08:29:47 UTC
(directory-server) 01/01: Apply search authorization to persistent search notifications
This is an automated email from the ASF dual-hosted git repository.
coheigea pushed a commit to branch coheigea/changesOnly
in repository https://gitbox.apache.org/repos/asf/directory-server.git
commit 77148141bb04716a449d1ac6caaff2ee4882bba6
Author: Colm O hEigeartaigh <co...@apache.org>
AuthorDate: Wed Oct 14 09:29:17 2026 +0100
Apply search authorization to persistent search notifications
---
.../server/core/api/event/DirectoryListener.java | 15 +
.../server/core/event/EventInterceptor.java | 33 ++-
.../ldap/handlers/PersistentSearchListener.java | 154 +++++++---
.../handlers/request/SearchRequestHandler.java | 14 +
.../search/PersistentSearchAuthorizationIT.java | 330 +++++++++++++++++++++
5 files changed, 490 insertions(+), 56 deletions(-)
diff --git a/core-api/src/main/java/org/apache/directory/server/core/api/event/DirectoryListener.java b/core-api/src/main/java/org/apache/directory/server/core/api/event/DirectoryListener.java
index 623a848c19..10f811d0c7 100644
--- a/core-api/src/main/java/org/apache/directory/server/core/api/event/DirectoryListener.java
+++ b/core-api/src/main/java/org/apache/directory/server/core/api/event/DirectoryListener.java
@@ -51,6 +51,21 @@ public interface DirectoryListener
void entryDeleted( DeleteOperationContext deleteContext );
+ /**
+ * Called synchronously, before an entry is deleted, for every listener which
+ * will be notified of the deletion. As the entry still exists at this point,
+ * this is the last chance for the listener to read it on behalf of its own
+ * session (for instance to check that this session is allowed to see it).
+ * The default implementation does nothing.
+ *
+ * @param deleteContext the delete operation context responsible for the change
+ */
+ default void beforeEntryDeleted( DeleteOperationContext deleteContext )
+ {
+ // Nothing to do by default
+ }
+
+
/**
* Called when an entry has been modified.
*
diff --git a/interceptors/event/src/main/java/org/apache/directory/server/core/event/EventInterceptor.java b/interceptors/event/src/main/java/org/apache/directory/server/core/event/EventInterceptor.java
index 6a47e081c6..eb7064796f 100644
--- a/interceptors/event/src/main/java/org/apache/directory/server/core/event/EventInterceptor.java
+++ b/interceptors/event/src/main/java/org/apache/directory/server/core/event/EventInterceptor.java
@@ -27,9 +27,9 @@ import static org.apache.directory.api.ldap.model.message.SearchScope.SUBTREE;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
-import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
+import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
@@ -96,7 +96,6 @@ public class EventInterceptor extends BaseInterceptor
super.init( directoryService );
evaluator = new ExpressionEvaluator( schemaManager );
- executor = new ThreadPoolExecutor( 1, 10, 1000, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<Runnable>( 100 ) );
ThreadFactory threadFactory = new ThreadFactory()
{
@@ -110,8 +109,13 @@ public class EventInterceptor extends BaseInterceptor
}
};
- executor = new ThreadPoolExecutor( 1, 10, 1000, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<Runnable>( 100 ),
- threadFactory );
+ // The listeners may have to wait for the end of the operation which notifies them
+ // (persistent search listeners read the modified entry), so the queue must not be
+ // bounded : a rejected notification would make this operation fail.
+ ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor( 10, 10, 1000, TimeUnit.MILLISECONDS,
+ new LinkedBlockingQueue<Runnable>(), threadFactory );
+ threadPoolExecutor.allowCoreThreadTimeOut( true );
+ executor = threadPoolExecutor;
this.directoryService.setEventService( new DefaultEventService( directoryService ) );
LOG.info( "Initialization complete." );
@@ -276,22 +280,25 @@ public class EventInterceptor extends BaseInterceptor
@Override
public void delete( final DeleteOperationContext deleteContext ) throws LdapException
{
- next( deleteContext );
-
List<RegistrationEntry> selecting = getSelectingRegistrations( deleteContext.getDn(), deleteContext.getEntry() );
+ List<RegistrationEntry> notified = new ArrayList<>();
- if ( selecting.isEmpty() )
- {
- return;
- }
-
- for ( final RegistrationEntry registration : selecting )
+ // Give the listeners a chance to read the entry before it gets deleted
+ for ( RegistrationEntry registration : selecting )
{
if ( EventType.isDelete( registration.getCriteria().getEventMask() ) )
{
- fire( deleteContext, EventType.DELETE, registration.getListener() );
+ registration.getListener().beforeEntryDeleted( deleteContext );
+ notified.add( registration );
}
}
+
+ next( deleteContext );
+
+ for ( final RegistrationEntry registration : notified )
+ {
+ fire( deleteContext, EventType.DELETE, registration.getListener() );
+ }
}
diff --git a/protocol-ldap/src/main/java/org/apache/directory/server/ldap/handlers/PersistentSearchListener.java b/protocol-ldap/src/main/java/org/apache/directory/server/ldap/handlers/PersistentSearchListener.java
index f963156959..3a4b5ffd2e 100644
--- a/protocol-ldap/src/main/java/org/apache/directory/server/ldap/handlers/PersistentSearchListener.java
+++ b/protocol-ldap/src/main/java/org/apache/directory/server/ldap/handlers/PersistentSearchListener.java
@@ -20,26 +20,31 @@
package org.apache.directory.server.ldap.handlers;
+import java.util.Collections;
+import java.util.Map;
+import java.util.WeakHashMap;
+
+import org.apache.directory.api.ldap.model.constants.SchemaConstants;
+import org.apache.directory.api.ldap.model.cursor.Cursor;
import org.apache.directory.api.ldap.model.entry.Entry;
-import org.apache.directory.api.ldap.model.exception.LdapException;
+import org.apache.directory.api.ldap.model.filter.PresenceNode;
import org.apache.directory.api.ldap.model.message.AbandonListener;
import org.apache.directory.api.ldap.model.message.AbandonableRequest;
+import org.apache.directory.api.ldap.model.message.AliasDerefMode;
import org.apache.directory.api.ldap.model.message.SearchRequest;
import org.apache.directory.api.ldap.model.message.SearchResultEntry;
import org.apache.directory.api.ldap.model.message.SearchResultEntryImpl;
+import org.apache.directory.api.ldap.model.message.SearchScope;
import org.apache.directory.api.ldap.model.message.controls.ChangeType;
import org.apache.directory.api.ldap.model.message.controls.EntryChange;
import org.apache.directory.api.ldap.model.message.controls.EntryChangeImpl;
import org.apache.directory.api.ldap.model.message.controls.PersistentSearch;
-import org.apache.directory.api.ldap.model.schema.SchemaManager;
+import org.apache.directory.api.ldap.model.name.Dn;
import org.apache.directory.api.util.Strings;
-import org.apache.directory.server.core.api.entry.ClonedServerEntry;
-import org.apache.directory.server.core.api.entry.ServerEntryUtils;
import org.apache.directory.server.core.api.event.DirectoryListener;
import org.apache.directory.server.core.api.interceptor.context.AddOperationContext;
import org.apache.directory.server.core.api.interceptor.context.ChangeOperationContext;
import org.apache.directory.server.core.api.interceptor.context.DeleteOperationContext;
-import org.apache.directory.server.core.api.interceptor.context.LookupOperationContext;
import org.apache.directory.server.core.api.interceptor.context.ModifyOperationContext;
import org.apache.directory.server.core.api.interceptor.context.MoveAndRenameOperationContext;
import org.apache.directory.server.core.api.interceptor.context.MoveOperationContext;
@@ -59,6 +64,11 @@ import org.slf4j.LoggerFactory;
* cancels it. Hence time and size limits in normal search operations do not apply
* here.
*
+ * The changed entries are never sent as they are found in the operation context:
+ * each of them is read again through the session of the client which created the
+ * persistent search, so that the authentication and authorization checks of a
+ * normal search are applied before anything is sent back.
+ *
* @author <a href="mailto:dev@directory.apache.org">Apache Directory Project</a>
*/
public class PersistentSearchListener implements DirectoryListener, AbandonListener
@@ -68,8 +78,11 @@ public class PersistentSearchListener implements DirectoryListener, AbandonListe
final SearchRequest req;
final PersistentSearch psearchControl;
- private LookupOperationContext filterCtx;
- private SchemaManager schemaManager;
+ /** The attributes requested by the client */
+ private final String[] attributes;
+
+ /** The entries about to be deleted, as visible by the client */
+ private final Map<DeleteOperationContext, Entry> deletedEntries = Collections.synchronizedMap( new WeakHashMap<>() );
public PersistentSearchListener( LdapSession session, SearchRequest req )
{
@@ -77,9 +90,7 @@ public class PersistentSearchListener implements DirectoryListener, AbandonListe
this.req = req;
req.addAbandonListener( this );
this.psearchControl = ( PersistentSearch ) req.getControls().get( PersistentSearch.OID );
-
- filterCtx = new LookupOperationContext( session.getCoreSession(), req.getAttributes().toArray( Strings.EMPTY_STRING_ARRAY ) );
- schemaManager = session.getCoreSession().getDirectoryService().getSchemaManager();
+ attributes = req.getAttributes().toArray( Strings.EMPTY_STRING_ARRAY );
}
@@ -145,17 +156,15 @@ public class PersistentSearchListener implements DirectoryListener, AbandonListe
return;
}
+ Entry entry = getVisibleEntry( addContext.getDn() );
+
+ if ( entry == null )
+ {
+ return;
+ }
+
SearchResultEntry respEntry = new SearchResultEntryImpl( req.getMessageId() );
respEntry.setObjectName( addContext.getDn() );
-
- // the entry needs to be cloned cause addContext.getEntry() will only contain
- // the user provided values and all the operational attributes added during
- // Partition.add() will be applied in the cloned entry present inside it
- // if we don't clone then the attributes will not be filtered
- // e.x the operational attributes will also be sent even when a user requests
- // user attributes only
- Entry entry = new ClonedServerEntry( addContext.getEntry() );
- filterEntry( entry );
respEntry.setEntry( entry );
setECResponseControl( respEntry, addContext, ChangeType.ADD );
@@ -163,6 +172,29 @@ public class PersistentSearchListener implements DirectoryListener, AbandonListe
}
+ /**
+ * {@inheritDoc}
+ *
+ * The entry can't be read anymore once it has been deleted, so we read it now,
+ * and keep it until we are notified of the deletion.
+ */
+ @Override
+ public void beforeEntryDeleted( DeleteOperationContext deleteContext )
+ {
+ if ( !psearchControl.isNotificationEnabled( ChangeType.DELETE ) )
+ {
+ return;
+ }
+
+ Entry entry = getVisibleEntry( deleteContext.getDn() );
+
+ if ( entry != null )
+ {
+ deletedEntries.put( deleteContext, entry );
+ }
+ }
+
+
public void entryDeleted( DeleteOperationContext deleteContext )
{
if ( !psearchControl.isNotificationEnabled( ChangeType.DELETE ) )
@@ -170,10 +202,16 @@ public class PersistentSearchListener implements DirectoryListener, AbandonListe
return;
}
+ Entry entry = deletedEntries.remove( deleteContext );
+
+ if ( entry == null )
+ {
+ return;
+ }
+
SearchResultEntry respEntry = new SearchResultEntryImpl( req.getMessageId() );
respEntry.setObjectName( deleteContext.getDn() );
- filterEntry( deleteContext.getEntry() );
- respEntry.setEntry( deleteContext.getEntry() );
+ respEntry.setEntry( entry );
setECResponseControl( respEntry, deleteContext, ChangeType.DELETE );
session.getIoSession().write( respEntry );
}
@@ -186,11 +224,15 @@ public class PersistentSearchListener implements DirectoryListener, AbandonListe
return;
}
+ Entry entry = getVisibleEntry( modifyContext.getDn() );
+
+ if ( entry == null )
+ {
+ return;
+ }
+
SearchResultEntry respEntry = new SearchResultEntryImpl( req.getMessageId() );
respEntry.setObjectName( modifyContext.getDn() );
-
- Entry entry = new ClonedServerEntry( modifyContext.getAlteredEntry() );
- filterEntry( entry );
respEntry.setEntry( entry );
setECResponseControl( respEntry, modifyContext, ChangeType.MODIFY );
@@ -205,11 +247,15 @@ public class PersistentSearchListener implements DirectoryListener, AbandonListe
return;
}
+ Entry entry = getVisibleEntry( moveContext.getNewDn() );
+
+ if ( entry == null )
+ {
+ return;
+ }
+
SearchResultEntry respEntry = new SearchResultEntryImpl( req.getMessageId() );
respEntry.setObjectName( moveContext.getNewDn() );
-
- Entry entry = new ClonedServerEntry( moveContext.getModifiedEntry() );
- filterEntry( entry );
respEntry.setEntry( entry );
setECResponseControl( respEntry, moveContext, ChangeType.MODDN );
@@ -230,11 +276,15 @@ public class PersistentSearchListener implements DirectoryListener, AbandonListe
return;
}
+ Entry entry = getVisibleEntry( renameContext.getNewDn() );
+
+ if ( entry == null )
+ {
+ return;
+ }
+
SearchResultEntry respEntry = new SearchResultEntryImpl( req.getMessageId() );
- respEntry.setObjectName( renameContext.getModifiedEntry().getDn() );
-
- Entry entry = new ClonedServerEntry( renameContext.getModifiedEntry() );
- filterEntry( entry );
+ respEntry.setObjectName( entry.getDn() );
respEntry.setEntry( entry );
setECResponseControl( respEntry, renameContext, ChangeType.MODDN );
@@ -243,22 +293,40 @@ public class PersistentSearchListener implements DirectoryListener, AbandonListe
/**
- * A convenient method to filter the contents of an entry
- *
- * @see ServerEntryUtils#filterContents(SchemaManager, org.apache.directory.server.core.api.interceptor.context.FilteringOperationContext, Entry)
- *
- * @param entry
+ * Reads an entry the way a base level search done by the client would: the
+ * search goes through the client's session, so the entry is only returned if
+ * the client is authenticated and allowed to see it, and only with the attributes
+ * (and values) it is allowed to read. The userPassword attribute is also removed
+ * if the server is configured to hide it.
+ *
+ * @param dn The entry's Dn
+ * @return The entry as visible by the client, or null if it can't see it
*/
- private void filterEntry( Entry entry )
+ private Entry getVisibleEntry( Dn dn )
{
- try
+ try ( Cursor<Entry> cursor = session.getCoreSession().search( dn, SearchScope.OBJECT,
+ new PresenceNode( SchemaConstants.OBJECT_CLASS_AT ), AliasDerefMode.NEVER_DEREF_ALIASES, attributes ) )
{
- ServerEntryUtils.filterContents( schemaManager, filterCtx, entry );
+ if ( !cursor.next() )
+ {
+ return null;
+ }
+
+ Entry entry = cursor.get();
+
+ if ( session.getCoreSession().getDirectoryService().isPasswordHidden() )
+ {
+ entry.removeAttributes( SchemaConstants.USER_PASSWORD_AT );
+ }
+
+ return entry;
}
- catch ( LdapException e )
+ catch ( Exception e )
{
- // shouldn't happen, if it does then blow up
- throw new RuntimeException( e );
+ // The client is not allowed to see the entry, or the entry does not exist anymore
+ LOG.debug( "Entry {} not sent to the persistent search client: {}", dn, e.getMessage() );
+
+ return null;
}
}
-}
\ No newline at end of file
+}
diff --git a/protocol-ldap/src/main/java/org/apache/directory/server/ldap/handlers/request/SearchRequestHandler.java b/protocol-ldap/src/main/java/org/apache/directory/server/ldap/handlers/request/SearchRequestHandler.java
index a9310325cc..a65d402be2 100644
--- a/protocol-ldap/src/main/java/org/apache/directory/server/ldap/handlers/request/SearchRequestHandler.java
+++ b/protocol-ldap/src/main/java/org/apache/directory/server/ldap/handlers/request/SearchRequestHandler.java
@@ -44,6 +44,7 @@ import org.apache.directory.api.ldap.model.filter.EqualityNode;
import org.apache.directory.api.ldap.model.filter.ExprNode;
import org.apache.directory.api.ldap.model.filter.OrNode;
import org.apache.directory.api.ldap.model.filter.PresenceNode;
+import org.apache.directory.api.ldap.model.message.AliasDerefMode;
import org.apache.directory.api.ldap.model.message.Control;
import org.apache.directory.api.ldap.model.message.LdapResult;
import org.apache.directory.api.ldap.model.message.MessageTypeEnum;
@@ -149,6 +150,19 @@ public class SearchRequestHandler extends LdapRequestHandler<SearchRequest>
return;
}
}
+ else
+ {
+ // Even if only the changes are requested, the client must be allowed to
+ // search the base : do a base level search through the client's session,
+ // which will throw an exception if it isn't authenticated as required, or
+ // if the base does not exist
+ try ( Cursor<Entry> cursor = session.getCoreSession().search( req.getBase(), SearchScope.OBJECT,
+ new PresenceNode( SchemaConstants.OBJECT_CLASS_AT ), AliasDerefMode.NEVER_DEREF_ALIASES,
+ SchemaConstants.NO_ATTRIBUTE ) )
+ {
+ cursor.next();
+ }
+ }
if ( req.isAbandoned() )
{
diff --git a/server-integ/src/test/java/org/apache/directory/server/operations/search/PersistentSearchAuthorizationIT.java b/server-integ/src/test/java/org/apache/directory/server/operations/search/PersistentSearchAuthorizationIT.java
new file mode 100644
index 0000000000..e004aeead8
--- /dev/null
+++ b/server-integ/src/test/java/org/apache/directory/server/operations/search/PersistentSearchAuthorizationIT.java
@@ -0,0 +1,330 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ *
+ */
+package org.apache.directory.server.operations.search;
+
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.directory.api.ldap.model.entry.DefaultEntry;
+import org.apache.directory.api.ldap.model.entry.DefaultModification;
+import org.apache.directory.api.ldap.model.entry.Entry;
+import org.apache.directory.api.ldap.model.entry.ModificationOperation;
+import org.apache.directory.api.ldap.model.message.Response;
+import org.apache.directory.api.ldap.model.message.ResultCodeEnum;
+import org.apache.directory.api.ldap.model.message.SearchRequest;
+import org.apache.directory.api.ldap.model.message.SearchRequestImpl;
+import org.apache.directory.api.ldap.model.message.SearchResultDone;
+import org.apache.directory.api.ldap.model.message.SearchResultEntry;
+import org.apache.directory.api.ldap.model.message.SearchScope;
+import org.apache.directory.api.ldap.model.message.controls.PersistentSearch;
+import org.apache.directory.api.ldap.model.message.controls.PersistentSearchImpl;
+import org.apache.directory.api.ldap.model.name.Dn;
+import org.apache.directory.api.util.Network;
+import org.apache.directory.ldap.client.api.LdapNetworkConnection;
+import org.apache.directory.ldap.client.api.future.SearchFuture;
+import org.apache.directory.server.annotations.CreateLdapServer;
+import org.apache.directory.server.annotations.CreateTransport;
+import org.apache.directory.server.core.annotations.ApplyLdifs;
+import org.apache.directory.server.core.annotations.CreateDS;
+import org.apache.directory.server.core.integ.AbstractLdapTestUnit;
+import org.apache.directory.server.core.integ.ApacheDSTestExtension;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+
+/**
+ * Checks that the entries sent back to a persistent search client are subject to
+ * the same authentication and authorization checks as a normal search.
+ *
+ * @author <a href="mailto:dev@directory.apache.org">Apache Directory Project</a>
+ */
+@ExtendWith( { ApacheDSTestExtension.class } )
+@CreateDS(
+ name = "PersistentSearchAuthorizationIT",
+ enableAccessControl = true,
+ allowAnonAccess = false)
+@CreateLdapServer(
+ transports =
+ {
+ @CreateTransport(protocol = "LDAP")
+ })
+@ApplyLdifs(
+ {
+ "dn: uid=billyd,ou=users,ou=system",
+ "objectClass: top",
+ "objectClass: person",
+ "objectClass: organizationalPerson",
+ "objectClass: inetOrgPerson",
+ "uid: billyd",
+ "cn: Billy D",
+ "sn: D",
+ "userPassword: billyd"
+})
+public class PersistentSearchAuthorizationIT extends AbstractLdapTestUnit
+{
+ private static final String BASE = "ou=system";
+ private static final String VICTIM_DN = "cn=Victim,ou=system";
+
+ /** The time we wait for a notification which should not come */
+ private static final long NO_RESPONSE_WAIT_MS = 2000L;
+
+ /** The time we wait for a notification which should come */
+ private static final long RESPONSE_WAIT_MS = 10000L;
+
+ private LdapNetworkConnection adminConnection;
+ private LdapNetworkConnection userConnection;
+
+
+ @BeforeEach
+ public void setUp() throws Exception
+ {
+ adminConnection = new LdapNetworkConnection( Network.LOOPBACK_HOSTNAME, getLdapServer().getPort() );
+ adminConnection.bind( "uid=admin,ou=system", "secret" );
+
+ userConnection = new LdapNetworkConnection( Network.LOOPBACK_HOSTNAME, getLdapServer().getPort() );
+ }
+
+
+ @AfterEach
+ public void tearDown() throws Exception
+ {
+ if ( adminConnection.exists( VICTIM_DN ) )
+ {
+ adminConnection.delete( VICTIM_DN );
+ }
+
+ adminConnection.close();
+ userConnection.close();
+ }
+
+
+ private SearchFuture startPersistentSearch( LdapNetworkConnection connection ) throws Exception
+ {
+ SearchRequest req = new SearchRequestImpl();
+ req.setBase( new Dn( BASE ) );
+ req.setScope( SearchScope.SUBTREE );
+ req.setFilter( "(objectClass=*)" );
+ req.addAttributes( "*" );
+
+ PersistentSearch psearch = new PersistentSearchImpl();
+ psearch.setChangesOnly( true );
+ psearch.setReturnECs( false );
+ psearch.setChangeTypes( 15 );
+ req.addControl( psearch );
+
+ return connection.searchAsync( req );
+ }
+
+
+ private Response nextResponse( SearchFuture future, long waitMs ) throws Exception
+ {
+ // Returns null if nothing has been received before the timeout
+ return future.get( waitMs, TimeUnit.MILLISECONDS );
+ }
+
+
+ /**
+ * Wait for the registration of the persistent search listeners
+ */
+ private void waitForListeners( int expected ) throws Exception
+ {
+ long deadline = System.currentTimeMillis() + RESPONSE_WAIT_MS;
+
+ while ( getService().getEventService().getRegistrationEntries().size() < expected )
+ {
+ assertTrue( System.currentTimeMillis() < deadline, "Persistent search listeners not registered" );
+ Thread.sleep( 50 );
+ }
+ }
+
+
+ private void addVictim() throws Exception
+ {
+ adminConnection.add( new DefaultEntry( VICTIM_DN,
+ "objectClass: top",
+ "objectClass: person",
+ "cn: Victim",
+ "sn: Victim",
+ "userPassword: s3cr3t" ) );
+ }
+
+
+ private void createReadAciWithoutPassword() throws Exception
+ {
+ adminConnection.modify( BASE, new DefaultModification( ModificationOperation.ADD_ATTRIBUTE,
+ "administrativeRole", "accessControlSpecificArea" ) );
+
+ adminConnection.add( new DefaultEntry( "cn=psearchAci,ou=system",
+ "objectClass: top",
+ "objectClass: subentry",
+ "objectClass: accessControlSubentry",
+ "subtreeSpecification: {}",
+ "prescriptiveACI: { identificationTag \"readAll\", precedence 10, authenticationLevel simple, "
+ + "itemOrUserFirst userFirst: { userClasses { allUsers }, userPermissions { "
+ + "{ protectedItems { entry, allUserAttributeTypesAndValues }, "
+ + "grantsAndDenials { grantRead, grantReturnDN, grantBrowse } }, "
+ + "{ protectedItems { attributeType { userPassword }, allAttributeValues { userPassword } }, "
+ + "grantsAndDenials { denyRead, denyBrowse } } } } }" ) );
+ }
+
+
+ /**
+ * A user without any read permission must not be notified of the changes made
+ * by the administrator, whereas the administrator is.
+ */
+ @Test
+ public void testChangesOnlyWithoutReadPermission() throws Exception
+ {
+ userConnection.bind( "uid=billyd,ou=users,ou=system", "billyd" );
+ int initialListeners = getService().getEventService().getRegistrationEntries().size();
+
+ SearchFuture userFuture = startPersistentSearch( userConnection );
+ SearchFuture adminFuture = startPersistentSearch( adminConnection );
+ waitForListeners( initialListeners + 2 );
+
+ addVictim();
+ adminConnection.modify( VICTIM_DN, new DefaultModification( ModificationOperation.REPLACE_ATTRIBUTE,
+ "userPassword", "n3wS3cr3t" ) );
+ adminConnection.delete( VICTIM_DN );
+
+ // The administrator gets the notifications...
+ Response adminResponse = nextResponse( adminFuture, RESPONSE_WAIT_MS );
+ assertNotNull( adminResponse );
+ assertTrue( adminResponse instanceof SearchResultEntry );
+ assertEquals( VICTIM_DN, ( ( SearchResultEntry ) adminResponse ).getObjectName().getName() );
+
+ // ... but not the user, who is not allowed to read anything
+ assertNull( nextResponse( userFuture, NO_RESPONSE_WAIT_MS ) );
+ }
+
+
+ /**
+ * A user allowed to read entries, but not the userPassword attribute, must be
+ * notified of the changes without the userPassword attribute.
+ */
+ @Test
+ public void testChangesOnlyHonoursAci() throws Exception
+ {
+ createReadAciWithoutPassword();
+
+ userConnection.bind( "uid=billyd,ou=users,ou=system", "billyd" );
+ int initialListeners = getService().getEventService().getRegistrationEntries().size();
+
+ SearchFuture userFuture = startPersistentSearch( userConnection );
+ waitForListeners( initialListeners + 1 );
+
+ addVictim();
+
+ Response response = nextResponse( userFuture, RESPONSE_WAIT_MS );
+ assertNotNull( response );
+ assertTrue( response instanceof SearchResultEntry );
+
+ Entry added = ( ( SearchResultEntry ) response ).getEntry();
+ assertEquals( VICTIM_DN, added.getDn().getName() );
+ assertTrue( added.contains( "sn", "Victim" ) );
+ assertFalse( added.containsAttribute( "userPassword" ) );
+
+ // The deletion is also notified, without the password
+ adminConnection.delete( VICTIM_DN );
+
+ response = nextResponse( userFuture, RESPONSE_WAIT_MS );
+ assertNotNull( response );
+ assertTrue( response instanceof SearchResultEntry );
+
+ Entry deleted = ( ( SearchResultEntry ) response ).getEntry();
+ assertEquals( VICTIM_DN, deleted.getDn().getName() );
+ assertFalse( deleted.containsAttribute( "userPassword" ) );
+ }
+
+
+ /**
+ * An anonymous client must not be able to register a persistent search when
+ * anonymous access is disabled.
+ */
+ @Test
+ public void testChangesOnlyAnonymousRejected() throws Exception
+ {
+ userConnection.connect();
+ int initialListeners = getService().getEventService().getRegistrationEntries().size();
+
+ SearchFuture future = startPersistentSearch( userConnection );
+
+ Response response = nextResponse( future, RESPONSE_WAIT_MS );
+ assertNotNull( response );
+ assertTrue( response instanceof SearchResultDone );
+ assertNotEquals( ResultCodeEnum.SUCCESS, ( ( SearchResultDone ) response ).getLdapResult().getResultCode() );
+ assertEquals( initialListeners, getService().getEventService().getRegistrationEntries().size() );
+
+ addVictim();
+
+ assertNull( nextResponse( future, NO_RESPONSE_WAIT_MS ) );
+ }
+
+
+ /**
+ * The notifications must not make a write operation fail, even when there are
+ * more listeners than the event executor can run at once.
+ */
+ @Test
+ public void testManyListenersDoNotBreakWrites() throws Exception
+ {
+ int initialListeners = getService().getEventService().getRegistrationEntries().size();
+ List<LdapNetworkConnection> connections = new ArrayList<>();
+
+ try
+ {
+ for ( int i = 0; i < 4; i++ )
+ {
+ LdapNetworkConnection connection = new LdapNetworkConnection( Network.LOOPBACK_HOSTNAME,
+ getLdapServer().getPort() );
+ connection.bind( "uid=admin,ou=system", "secret" );
+ connections.add( connection );
+
+ for ( int j = 0; j < 40; j++ )
+ {
+ startPersistentSearch( connection );
+ }
+ }
+
+ waitForListeners( initialListeners + 160 );
+
+ addVictim();
+ assertTrue( adminConnection.exists( VICTIM_DN ) );
+ }
+ finally
+ {
+ for ( LdapNetworkConnection connection : connections )
+ {
+ connection.close();
+ }
+ }
+ }
+}