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();
+            }
+        }
+    }
+}