From 77148141bb04716a449d1ac6caaff2ee4882bba6 Mon Sep 17 00:00:00 2001 From: Colm O hEigeartaigh Date: Wed, 14 Oct 2026 09:29:17 +0100 Subject: [PATCH] Apply search authorization to persistent search notifications --- .../core/api/event/DirectoryListener.java | 15 + .../server/core/event/EventInterceptor.java | 33 +- .../handlers/PersistentSearchListener.java | 154 +++++--- .../request/SearchRequestHandler.java | 14 + .../PersistentSearchAuthorizationIT.java | 330 ++++++++++++++++++ 5 files changed, 490 insertions(+), 56 deletions(-) create mode 100644 server-integ/src/test/java/org/apache/directory/server/operations/search/PersistentSearchAuthorizationIT.java 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 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 void init( DirectoryService directoryService ) throws LdapException super.init( directoryService ); evaluator = new ExpressionEvaluator( schemaManager ); - executor = new ThreadPoolExecutor( 1, 10, 1000, TimeUnit.MILLISECONDS, new ArrayBlockingQueue( 100 ) ); ThreadFactory threadFactory = new ThreadFactory() { @@ -110,8 +109,13 @@ public Thread newThread( Runnable runnable ) } }; - executor = new ThreadPoolExecutor( 1, 10, 1000, TimeUnit.MILLISECONDS, new ArrayBlockingQueue( 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(), threadFactory ); + threadPoolExecutor.allowCoreThreadTimeOut( true ); + executor = threadPoolExecutor; this.directoryService.setEventService( new DefaultEventService( directoryService ) ); LOG.info( "Initialization complete." ); @@ -276,22 +280,25 @@ public void add( final AddOperationContext addContext ) throws LdapException @Override public void delete( final DeleteOperationContext deleteContext ) throws LdapException { - next( deleteContext ); - List selecting = getSelectingRegistrations( deleteContext.getDn(), deleteContext.getEntry() ); + List 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 @@ * 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 Apache Directory Project */ 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 deletedEntries = Collections.synchronizedMap( new WeakHashMap<>() ); public PersistentSearchListener( LdapSession session, SearchRequest req ) { @@ -77,9 +90,7 @@ public PersistentSearchListener( LdapSession session, SearchRequest req ) 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 void entryAdded( AddOperationContext addContext ) 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 void entryAdded( AddOperationContext addContext ) } + /** + * {@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 void entryDeleted( DeleteOperationContext deleteContext ) 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 void entryModified( ModifyOperationContext modifyContext ) 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 void entryMoved( MoveOperationContext moveContext ) 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 void entryRenamed( RenameOperationContext renameContext ) 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 void entryRenamed( RenameOperationContext renameContext ) /** - * 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 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.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 @@ private void handlePersistentSearch( LdapSession session, SearchRequest req, Per 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 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 Apache Directory Project + */ +@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 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(); + } + } + } +}