diff --git a/resources/ldap-schema/core.ldif b/resources/ldap-schema/core.ldif index beed3058..d2de40d2 100644 --- a/resources/ldap-schema/core.ldif +++ b/resources/ldap-schema/core.ldif @@ -107,6 +107,15 @@ attributeTypes: ( 2.5.21.7 NAME 'nameForms' DESC 'name forms' EQUALITY 2.5.13.30 attributeTypes: ( 2.5.21.1 NAME 'dITStructureRules' DESC 'DIT structure rules' EQUALITY 2.5.13.29 SYNTAX 1.3.6.1.4.1.1466.115.121.1.17 NO-USER-MODIFICATION USAGE directoryOperation ) attributeTypes: ( 2.5.18.5 NAME 'administrativeRole' DESC 'administrative roles the entry is an administrative point for' EQUALITY 2.5.13.0 SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 USAGE directoryOperation ) attributeTypes: ( 2.5.18.6 NAME 'subtreeSpecification' DESC 'the portion of a subtree a subentry applies to' SYNTAX 1.3.6.1.4.1.1466.115.121.1.45 SINGLE-VALUE USAGE directoryOperation ) +attributeTypes: ( 1.3.6.1.4.1.1466.101.120.6 NAME 'altServer' DESC 'URIs of other servers replicating the same information' SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 USAGE dSAOperation ) +attributeTypes: ( 1.3.6.1.4.1.1466.101.120.5 NAME 'namingContexts' DESC 'naming contexts the server masters or shadows' SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 USAGE dSAOperation ) +attributeTypes: ( 1.3.6.1.4.1.1466.101.120.13 NAME 'supportedControl' DESC 'request controls the server recognizes' SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 USAGE dSAOperation ) +attributeTypes: ( 1.3.6.1.4.1.1466.101.120.7 NAME 'supportedExtension' DESC 'extended operations the server recognizes' SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 USAGE dSAOperation ) +attributeTypes: ( 1.3.6.1.4.1.4203.1.3.5 NAME 'supportedFeatures' DESC 'elective features the server supports' EQUALITY 2.5.13.0 SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 USAGE dSAOperation ) +attributeTypes: ( 1.3.6.1.4.1.1466.101.120.15 NAME 'supportedLDAPVersion' DESC 'LDAP versions the server implements' SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 USAGE dSAOperation ) +attributeTypes: ( 1.3.6.1.4.1.1466.101.120.14 NAME 'supportedSASLMechanisms' DESC 'SASL mechanisms the server recognizes' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 USAGE dSAOperation ) +attributeTypes: ( 1.3.6.1.1.4 NAME 'vendorName' DESC 'name of the server implementor' EQUALITY 1.3.6.1.4.1.1466.109.114.1 SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE NO-USER-MODIFICATION USAGE dSAOperation ) +attributeTypes: ( 1.3.6.1.1.5 NAME 'vendorVersion' DESC 'version of the server implementation' EQUALITY 1.3.6.1.4.1.1466.109.114.1 SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE NO-USER-MODIFICATION USAGE dSAOperation ) objectClasses: ( 2.5.6.0 NAME 'top' DESC 'top of the class hierarchy' ABSTRACT MUST objectClass ) objectClasses: ( 2.5.6.1 NAME 'alias' DESC 'an alias entry pointing at another entry' SUP 2.5.6.0 STRUCTURAL MUST aliasedObjectName ) objectClasses: ( 2.5.6.6 NAME 'person' DESC 'natural persons' SUP 2.5.6.0 STRUCTURAL MUST ( sn $ cn ) MAY ( userPassword $ telephoneNumber $ seeAlso $ description ) ) diff --git a/src/FreeDSx/Ldap/Container/Provider/HandlerContainerProvider.php b/src/FreeDSx/Ldap/Container/Provider/HandlerContainerProvider.php index 19e99255..dc020430 100644 --- a/src/FreeDSx/Ldap/Container/Provider/HandlerContainerProvider.php +++ b/src/FreeDSx/Ldap/Container/Provider/HandlerContainerProvider.php @@ -162,6 +162,7 @@ private function makeGeneratedEntryResponder(Container $container): GeneratedEnt return new GeneratedEntryResponder( $container->get(AccessControlInterface::class), $container->get(FilterEvaluatorInterface::class), + $container->get(ServerOptions::class)->getSchema(), ); } diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/AttributeProjection.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/AttributeProjection.php index 0e72af87..def0a476 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/AttributeProjection.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/AttributeProjection.php @@ -21,10 +21,12 @@ /** * Projects an entry onto a search request's attribute selection list (RFC 4511 ยง4.5.1.8, RFC 3673). * - * - empty list / "*" = all attributes already on the entry + * - empty list / "*" = all user attributes (operational ones only when also named) * - "+" = all operational attributes (classified via the supplied schema) * - "1.1" = no attributes (DN only) - * - explicit names = only those + * - explicit names = only those, whatever their usage + * + * An attribute the schema does not define counts as a user attribute, so unknown ones keep flowing. * * @author Chad Sikorra */ @@ -37,12 +39,19 @@ final class AttributeProjection */ private array $operationalByName = []; + /** + * Per-instance memo of the selection decision keyed on attribute description. + * + * @var array + */ + private array $includeByDescription = []; + /** * @param string[] $names */ private function __construct( private readonly array $names, - private readonly bool $returnAll, + private readonly bool $wantsUser, private readonly bool $wantsOperational, private readonly bool $returnNone, private readonly bool $typesOnly, @@ -74,14 +83,11 @@ public static function forRequest( public function project(Entry $entry): Entry { - if ($this->isPassThrough()) { - return $entry; - } - + $attributes = $entry->getAttributes(); $filteredAttributes = []; if (!$this->returnNone) { - foreach ($entry->getAttributes() as $attribute) { + foreach ($attributes as $attribute) { if (!$this->shouldInclude($attribute)) { continue; } @@ -92,29 +98,32 @@ public function project(Entry $entry): Entry } } + // Nothing was withheld, so the entry already is its own projection. + if (!$this->typesOnly && count($filteredAttributes) === count($attributes)) { + return $entry; + } + return Entry::raw( $entry->getDn(), $filteredAttributes, ); } - private function isPassThrough(): bool + private function shouldInclude(Attribute $attribute): bool { - return $this->names === [] && !$this->typesOnly; + return $this->includeByDescription[$attribute->getDescription()] + ??= $this->decideInclude($attribute); } - private function shouldInclude(Attribute $attribute): bool + private function decideInclude(Attribute $attribute): bool { - if ($this->returnAll) { - return true; - } - - if (in_array(strtolower($attribute->getDescription()), $this->names, true)) { + if ($this->names !== [] && in_array(strtolower($attribute->getDescription()), $this->names, true)) { return true; } - return $this->wantsOperational - && $this->isOperational($attribute); + return $this->isOperational($attribute) + ? $this->wantsOperational + : $this->wantsUser; } private function isOperational(Attribute $attribute): bool diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/GeneratedEntryResponder.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/GeneratedEntryResponder.php index ad94c0ed..8aee07c2 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/GeneratedEntryResponder.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/GeneratedEntryResponder.php @@ -20,6 +20,7 @@ use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\Queue\Response\ResponseStream; +use FreeDSx\Ldap\Schema\Schema; use FreeDSx\Ldap\Server\AccessControl\AccessControlInterface; use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; use FreeDSx\Ldap\Server\Operation\OperationOutcomeResult; @@ -37,6 +38,7 @@ public function __construct( private AccessControlInterface $accessControl, private FilterEvaluatorInterface $filterEvaluator, + private Schema $schema, ) {} /** @@ -77,6 +79,37 @@ public function matches( ); } + /** + * The whole response path for a synthesized entry: read policy, then the filter, then attribute selection. + * + * Prefer this over calling the steps directly, so every generated entry answers the request the same way. + */ + public function respondWith( + LdapMessageRequest $message, + Entry $entry, + TokenInterface $token, + ): ResponseStream { + $readable = $this->readable( + $entry, + $token, + ); + + if (!$this->matches($message, $readable)) { + return $this->reply( + $message, + null, + ); + } + + return $this->reply( + $message, + $this->project( + $message, + $readable, + ), + ); + } + /** * Replies with the entry, or with an empty result when it is null. */ @@ -94,4 +127,24 @@ public function reply( ...$responses, ); } + + /** + * Narrows the entry to the request's attribute selection. + */ + private function project( + LdapMessageRequest $message, + Entry $entry, + ): Entry { + $request = $message->getRequest(); + + if (!$request instanceof SearchRequest) { + return $entry; + } + + return AttributeProjection::forRequest( + $request->getAttributes(), + $request->getAttributesOnly(), + $this->schema, + )->project($entry); + } } diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerMonitorHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerMonitorHandler.php index 52a02ec7..1d437d9a 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerMonitorHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerMonitorHandler.php @@ -50,18 +50,14 @@ public function handleRequest( LdapMessageRequest $message, TokenInterface $token, ): ResponseStream { - $entry = $this->responder->readable( + return $this->responder->respondWith( + $message, Entry::fromArray( self::DN, $this->attributes($this->snapshots->snapshot()), ), $token, ); - - return $this->responder->reply( - $message, - $this->responder->matches($message, $entry) ? $entry : null, - ); } /** diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerRootDseHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerRootDseHandler.php index aeaf1f1b..ed15607e 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerRootDseHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerRootDseHandler.php @@ -16,7 +16,6 @@ use FreeDSx\Ldap\Control\Control; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Operation\Request\ExtendedRequest; -use FreeDSx\Ldap\Operation\Request\SearchRequest; use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\Queue\Response\ResponseStream; use FreeDSx\Ldap\Schema\Definition\ObjectClassOid; @@ -25,8 +24,6 @@ use FreeDSx\Ldap\Server\Token\TokenInterface; use FreeDSx\Ldap\ServerOptions; -use function count; - /** * Handles RootDSE based search requests. * @@ -74,6 +71,7 @@ public function handleRequest( Control::OID_POST_READ, Control::OID_SUBTREE_DELETE, Control::OID_SUBENTRIES, + Control::OID_PWD_POLICY, ], 'supportedExtension' => [ ExtendedRequest::OID_WHOAMI, @@ -113,58 +111,11 @@ public function handleRequest( $entry->set('altServer', (string) $this->options->getDseAltServer()); } - $entry = $this->responder->readable( - $entry, - $token, - ); - - // Stripping and matching both precede attribute selection, since selecting must not change what matched. - if (!$this->responder->matches($message, $entry)) { - return $this->responder->reply( - $message, - null, - ); - } - - /** @var SearchRequest $request */ - $request = $message->getRequest(); - $this->filterEntryAttributes($request, $entry); - - return $this->responder->reply( + // Every attribute here is operational, so "+" is the only wildcard that selects them (RFC 4512 section 5.1). + return $this->responder->respondWith( $message, $entry, + $token, ); } - - /** - * Filters attributes from an entry to return only what was requested. - */ - private function filterEntryAttributes( - SearchRequest $request, - Entry $entry, - ): void { - if (count($request->getAttributes()) !== 0) { - foreach ($entry->getAttributes() as $dseAttr) { - $found = false; - foreach ($request->getAttributes() as $attribute) { - if ($attribute->equals($dseAttr)) { - $found = true; - break; - } - } - if ($found === true && $request->getAttributesOnly()) { - $dseAttr->reset(); - } - if ($found === false) { - $entry->reset($dseAttr); - $entry->changes()->reset(); - } - } - } - if ($request->getAttributesOnly()) { - foreach ($entry->getAttributes() as $attribute) { - $attribute->reset(); - } - } - } } diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchTrait.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchTrait.php index dda88475..28baab55 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchTrait.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchTrait.php @@ -121,6 +121,14 @@ private function getPagingControlFromMessage(LdapMessageRequest $message): Pagin ); } + // The size is constrained to (0..maxInt), and a negative one would otherwise mean an unbounded page. + if ($pagingControl->getSize() < 0) { + throw new OperationException( + 'The paged results size must not be negative.', + ResultCode::PROTOCOL_ERROR, + ); + } + return $pagingControl; } diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSubschemaHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSubschemaHandler.php index 4b3907bf..bf311114 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSubschemaHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSubschemaHandler.php @@ -70,15 +70,12 @@ public function handleRequest( ]), ); - $entry = $this->responder->readable( + // The schema definitions are operational, so a client that does not ask for them gets the naming attributes only. + return $this->responder->respondWith( + $message, $entry, $token, ); - - return $this->responder->reply( - $message, - $this->responder->matches($message, $entry) ? $entry : null, - ); } /** diff --git a/src/FreeDSx/Ldap/Server/Middleware/ServerControlRegistry.php b/src/FreeDSx/Ldap/Server/Middleware/ServerControlRegistry.php index 68d69254..eb0245e8 100644 --- a/src/FreeDSx/Ldap/Server/Middleware/ServerControlRegistry.php +++ b/src/FreeDSx/Ldap/Server/Middleware/ServerControlRegistry.php @@ -29,11 +29,13 @@ final class ServerControlRegistry /** * Controls accepted on every handler that runs the check. Proxied authorization is global because the * RFC 4370 eligibility gate runs upstream in ProxiedAuthorizationResolver, not here. ManageDsaIT is global - * because it is recognized server-wide and treated as inert (no referral entries to reinterpret). + * because it is recognized server-wide and treated as inert (no referral entries to reinterpret). The password + * policy request control is global because it may accompany any request, and its criticality may be TRUE. */ private const GLOBAL_CONTROLS = [ Control::OID_PROXY_AUTHORIZATION, Control::OID_MANAGE_DSA_IT, + Control::OID_PWD_POLICY, ]; /** diff --git a/tests/integration/LdapSaslServerTest.php b/tests/integration/LdapSaslServerTest.php index cba3e7d7..7a4552dd 100644 --- a/tests/integration/LdapSaslServerTest.php +++ b/tests/integration/LdapSaslServerTest.php @@ -116,7 +116,7 @@ public function testSaslScramSha256FailsWithInvalidCredentials(): void public function testRootDseAdvertisesSaslMechanisms(): void { - $rootDse = $this->ldapClient()->read(''); + $rootDse = $this->ldapClient()->read('', ['+']); $this->assertNotNull($rootDse); diff --git a/tests/integration/LdapServerTest.php b/tests/integration/LdapServerTest.php index 8d5891c0..bf9f167e 100644 --- a/tests/integration/LdapServerTest.php +++ b/tests/integration/LdapServerTest.php @@ -14,6 +14,8 @@ namespace Tests\Integration\FreeDSx\Ldap; use FreeDSx\Ldap\ClientOptions; +use FreeDSx\Ldap\Control\Control; +use FreeDSx\Ldap\Control\PagingControl; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Entry\Rdn; use FreeDSx\Ldap\Exception\BindException; @@ -175,7 +177,7 @@ public function testItCanModifyDn(): void public function testItCanRetrieveTheRootDSE(): void { - $rootDse = $this->ldapClient()->read(); + $rootDse = $this->ldapClient()->read('', ['*', '+']); $this->assertNotNull($rootDse); $this->assertSame( @@ -200,6 +202,7 @@ public function testItCanRetrieveTheRootDSE(): void '1.3.6.1.1.13.2', '1.2.840.113556.1.4.805', '1.3.6.1.4.1.4203.1.10.1', + '1.3.6.1.4.1.42.2.27.8.5.1', '1.3.6.1.4.1.4203.1.9.1.1', ], 'supportedExtension' => [ @@ -224,6 +227,93 @@ public function testItCanRetrieveTheRootDSE(): void ); } + public function testTheRootDseReturnsItsOperationalAttributesForThePlusSelector(): void + { + $rootDse = $this->ldapClient()->read('', ['+']); + + $this->assertNotNull($rootDse); + $this->assertSame( + [ + 'namingContexts', + 'subschemaSubentry', + 'supportedControl', + 'supportedExtension', + 'supportedFeatures', + 'supportedLDAPVersion', + 'vendorName', + ], + array_keys($rootDse->toArray()), + ); + } + + public function testABareRootDseReadReturnsOnlyItsUserAttributes(): void + { + foreach ([[], ['*']] as $selectors) { + $rootDse = $this->ldapClient()->read('', $selectors); + + $this->assertNotNull($rootDse); + $this->assertSame( + ['objectClass'], + array_keys($rootDse->toArray()), + ); + } + } + + public function testTheRootDseReturnsNoAttributesForTheNoAttributesSelector(): void + { + $rootDse = $this->ldapClient()->read('', ['1.1']); + + $this->assertNotNull($rootDse); + $this->assertSame( + [], + $rootDse->toArray(), + ); + } + + public function testASearchWithholdsOperationalAttributesUnlessTheyAreAsked(): void + { + $this->authenticateUser(); + $dn = 'cn=user,dc=foo,dc=bar'; + + $this->assertSame( + ['entryUUID'], + $this->attributeNamesOf($dn, ['entryUUID']), + ); + + foreach ([[], ['*']] as $selectors) { + $names = $this->attributeNamesOf($dn, $selectors); + + $this->assertContains('cn', $names); + $this->assertNotContains('entryUUID', $names); + $this->assertNotContains('createTimestamp', $names); + } + } + + public function testTheStarSelectorAlsoReturnsAnOperationalAttributeNamedAlongsideIt(): void + { + $this->authenticateUser(); + + $names = $this->attributeNamesOf( + 'cn=user,dc=foo,dc=bar', + ['*', 'entryUUID'], + ); + + $this->assertContains('cn', $names); + $this->assertContains('entryUUID', $names); + $this->assertNotContains('createTimestamp', $names); + } + + public function testTheRootDseAdvertisesThePasswordPolicyControl(): void + { + $rootDse = $this->ldapClient()->read('', ['supportedControl']); + + $this->assertNotNull($rootDse); + $this->assertContains( + Control::OID_PWD_POLICY, + $rootDse->get('supportedControl')?->getValues() ?? [], + ); + } + public function testThatOperationCompareRequireAuthentication(): void { $this->expectException(OperationException::class); @@ -719,6 +809,49 @@ public function testItCanEndPagingEarly(): void $this->assertFalse($paging->hasEntries()); } + public function testANegativePageSizeIsRejectedOnASubsequentPagingRequest(): void + { + $this->authenticateUser(); + + $search = Operations::search(Filters::present('objectClass'))->base('dc=foo,dc=bar'); + + $response = $this->ldapClient()->sendAndReceive( + $search, + new PagingControl(1, ''), + ); + $paging = $response->controls()->get(Control::OID_PAGING); + + $this->assertInstanceOf( + PagingControl::class, + $paging, + ); + $this->assertNotSame( + '', + $paging->getCookie(), + ); + + $this->expectException(OperationException::class); + $this->expectExceptionCode(ResultCode::PROTOCOL_ERROR); + + $this->ldapClient()->sendAndReceive( + $search, + new PagingControl(-1, $paging->getCookie()), + ); + } + + public function testANegativePageSizeIsRejectedOnTheInitialPagingRequest(): void + { + $this->authenticateUser(); + + $this->expectException(OperationException::class); + $this->expectExceptionCode(ResultCode::PROTOCOL_ERROR); + + $this->ldapClient()->sendAndReceive( + Operations::search(Filters::present('objectClass'))->base('dc=foo,dc=bar'), + new PagingControl(-1, ''), + ); + } + public function testAnUnfinishedPagingSessionIsDiscardedOnceTheCapIsReached(): void { $this->createServerProcess( @@ -790,4 +923,17 @@ private function personEntry(string $cn): Entry 'sn' => ['Test'], ]); } + + /** + * @param list $selectors + * @return list + */ + private function attributeNamesOf( + string $dn, + array $selectors, + ): array { + return array_keys($this->ldapClient() + ->readOrFail($dn, $selectors) + ->toArray()); + } } diff --git a/tests/integration/Search/LdapGeneratedEntryFilterTest.php b/tests/integration/Search/LdapGeneratedEntryFilterTest.php index a3e4c5ed..d352425a 100644 --- a/tests/integration/Search/LdapGeneratedEntryFilterTest.php +++ b/tests/integration/Search/LdapGeneratedEntryFilterTest.php @@ -239,7 +239,7 @@ public function test_a_denied_root_dse_attribute_is_stripped_but_the_entry_still $this->stopServer(); $this->createServerProcess('tcp', ['--monitor', '--hide-rootdse-vendor']); - $entries = $this->searchBase('', Filters::present('objectClass')); + $entries = $this->searchBase('', Filters::present('objectClass'), ['+']); $entry = $entries->first(); self::assertNotNull($entry); @@ -247,6 +247,69 @@ public function test_a_denied_root_dse_attribute_is_stripped_but_the_entry_still self::assertNotNull($entry->get('supportedSaslMechanisms') ?? $entry->get('namingContexts')); } + public function test_the_subschema_withholds_its_definitions_unless_they_are_asked_for(): void + { + $this->authenticateUser(); + + self::assertSame( + ['objectClass', 'cn'], + $this->attributeNamesOf(self::SUBSCHEMA_DN, []), + ); + self::assertContains( + 'objectClasses', + $this->attributeNamesOf(self::SUBSCHEMA_DN, ['+']), + ); + } + + /** + * The filter runs against the whole entry, so narrowing the selection must not change what matched. + */ + public function test_a_schema_definition_still_matches_when_it_is_not_selected(): void + { + $this->authenticateUser(); + + $entries = $this->searchBase( + self::SUBSCHEMA_DN, + Filters::equal('objectClasses', '2.5.6.6'), + ['cn'], + ); + + self::assertCount(1, $entries); + self::assertSame( + ['cn'], + array_keys($entries->first()?->toArray() ?? []), + ); + } + + public function test_the_monitor_entry_narrows_to_the_requested_attributes(): void + { + $this->authenticateAdmin(); + + self::assertSame( + ['connectionsActive'], + $this->attributeNamesOf(self::MONITOR_DN, ['connectionsActive']), + ); + } + + /** + * @param list $attributes + * @return list + */ + private function attributeNamesOf( + string $base, + array $attributes, + ): array { + $entry = $this->searchBase( + $base, + Filters::present('objectClass'), + $attributes, + )->first(); + + self::assertNotNull($entry); + + return array_keys($entry->toArray()); + } + /** * @param list $attributes */ diff --git a/tests/integration/Search/LdapSubentryVisibilityTest.php b/tests/integration/Search/LdapSubentryVisibilityTest.php index 1fc9e831..cb0efbf3 100644 --- a/tests/integration/Search/LdapSubentryVisibilityTest.php +++ b/tests/integration/Search/LdapSubentryVisibilityTest.php @@ -186,7 +186,7 @@ public function test_the_control_is_accepted_rather_than_rejected_as_critical(): public function test_the_root_dse_advertises_the_control(): void { - $rootDse = $this->ldapClient()->readOrFail(''); + $rootDse = $this->ldapClient()->readOrFail('', ['supportedControl']); self::assertContains( Control::OID_SUBENTRIES, diff --git a/tests/integration/Security/LdapPasswordPolicyServerTest.php b/tests/integration/Security/LdapPasswordPolicyServerTest.php index 26235913..d680efeb 100644 --- a/tests/integration/Security/LdapPasswordPolicyServerTest.php +++ b/tests/integration/Security/LdapPasswordPolicyServerTest.php @@ -18,6 +18,9 @@ use FreeDSx\Ldap\Control\PwdPolicyResponseControl; use FreeDSx\Ldap\Controls; use FreeDSx\Ldap\Exception\BindException; +use FreeDSx\Ldap\Operation\Response\ExtendedResponse; +use FreeDSx\Ldap\Operations; +use FreeDSx\Ldap\Search\Filters; use Tests\Integration\FreeDSx\Ldap\ServerTestCase; final class LdapPasswordPolicyServerTest extends ServerTestCase @@ -74,6 +77,47 @@ public function testBindWithoutPolicyStateCarriesNoControl(): void ); } + public function testACriticalPasswordPolicyControlIsAcceptedOnANonBindOperation(): void + { + $this->ldapClient()->bind( + 'cn=user,dc=foo,dc=bar', + self::PASSWORD, + ); + + // The control may accompany any request, so a critical one must not be refused outside the bind. + $response = $this->ldapClient()->sendAndReceive( + Operations::whoami(), + Controls::pwdPolicy(), + )->getResponse(); + + $this->assertInstanceOf( + ExtendedResponse::class, + $response, + ); + $this->assertSame( + 'dn:cn=user,dc=foo,dc=bar', + $response->getValue(), + ); + } + + public function testACriticalPasswordPolicyControlIsAcceptedOnASearch(): void + { + $this->ldapClient()->bind( + 'cn=user,dc=foo,dc=bar', + self::PASSWORD, + ); + + $entries = $this->ldapClient()->search( + Operations::search(Filters::present('objectClass'))->base('dc=foo,dc=bar'), + Controls::pwdPolicy(), + ); + + $this->assertGreaterThan( + 0, + $entries->count(), + ); + } + public function testAUserGovernedByAPolicySubentryIsLockedOut(): void { $this->useSubentryPolicyServer(); diff --git a/tests/performance/Compare/BenchCompareCommand.php b/tests/performance/Compare/BenchCompareCommand.php index 1f806d4b..d0713295 100644 --- a/tests/performance/Compare/BenchCompareCommand.php +++ b/tests/performance/Compare/BenchCompareCommand.php @@ -272,6 +272,25 @@ protected function execute( $sourceSnapshot = null; $benches = []; + // Both sides are contacted first: seeding one side is wasted work if the other turns out to be unreachable. + $unreachable = $this->unreachableSides(array_values(array_filter([ + $skipTarget ? null : $targetSide, + $skipSource ? null : $sourceSide, + ]))); + + if ($unreachable !== []) { + foreach ($unreachable as $reason) { + $output->writeln('' . $reason . ''); + } + $output->writeln( + 'This command benchmarks running servers rather than starting them. Bring the bench stack up ' + . 'with "composer profile-up" (or use "composer compare-ldap", which does both), point --source-port / ' + . '--target-port at your own servers, or pass --skip-target / --skip-source to run one side.', + ); + + return Command::FAILURE; + } + try { if (!$skipTarget) { $targetSnapshot = $this->runSide($output, $progress, $targetSide, $params, $benches); @@ -299,6 +318,40 @@ protected function execute( return Command::SUCCESS; } + /** + * @param list $sides + * @return list one message per side that could not be reached, empty when all are usable + */ + private function unreachableSides(array $sides): array + { + $reasons = []; + + foreach ($sides as $side) { + $bench = $this->makeBench($side); + + try { + $bench->preflight(); + } catch (Throwable $e) { + $reasons[] = $e->getMessage(); + } finally { + $bench->close(); + } + } + + return $reasons; + } + + private function makeBench(BenchSide $side): TargetBench + { + return new TargetBench( + host: $side->host, + port: $side->port, + bindDn: $side->bindDn, + bindPassword: $side->bindPassword, + rootBaseDn: $side->baseDn, + ); + } + /** * @param array{duration: ?int, ops: ?int, mix: string, clients: int, warmup: int, rngSeed: ?int, seedEntries: int, jit: bool, searchSizeLimit: int, searchValue: string, driverProcesses: int} $params * @param list $benches @@ -310,13 +363,7 @@ private function runSide( array $params, array &$benches, ): StatsSnapshot { - $bench = new TargetBench( - host: $side->host, - port: $side->port, - bindDn: $side->bindDn, - bindPassword: $side->bindPassword, - rootBaseDn: $side->baseDn, - ); + $bench = $this->makeBench($side); $benches[] = $bench; $this->seedSide( diff --git a/tests/performance/Compare/MultiDriverCoordinator.php b/tests/performance/Compare/MultiDriverCoordinator.php index 9fd80237..637aead3 100644 --- a/tests/performance/Compare/MultiDriverCoordinator.php +++ b/tests/performance/Compare/MultiDriverCoordinator.php @@ -198,6 +198,7 @@ private function configForChild(Config $base, int $childIndex): Config jit: $base->jit, searchSizeLimit: $base->searchSizeLimit, searchValue: $base->searchValue, + workerIdOffset: $childIndex * $base->clients, ); } diff --git a/tests/performance/Compare/TargetBench.php b/tests/performance/Compare/TargetBench.php index 5a9c4a7b..ef8c1218 100644 --- a/tests/performance/Compare/TargetBench.php +++ b/tests/performance/Compare/TargetBench.php @@ -31,6 +31,11 @@ */ final class TargetBench { + /** + * Kept under the smallest default size limit the benched servers enforce. + */ + private const CLEANUP_PAGE_SIZE = 500; + public readonly string $benchBaseDn; public readonly string $writeBaseDn; @@ -70,6 +75,14 @@ public function mailDomain(): string ); } + /** + * Proves the server is reachable and the credentials work, so a bad side is reported before anything is seeded. + */ + public function preflight(): void + { + $this->bindIfNeeded(); + } + public function seed(int $seedEntries): void { $this->bindIfNeeded(); @@ -109,26 +122,7 @@ public function cleanup(): void // The seed connection may have gone idle and been dropped during the run, so reconnect for a reliable teardown. $this->reconnect(); - try { - $entries = $this->client->search( - Operations::search( - Filters::present('objectClass'), - ) - ->base($this->benchBaseDn) - ->useSubtreeScope(), - ); - } catch (OperationException $e) { - if ($e->getCode() === ResultCode::NO_SUCH_OBJECT) { - return; - } - - throw $e; - } - - $dns = []; - foreach ($entries as $entry) { - $dns[] = (string) $entry->getDn(); - } + $dns = $this->subtreeDns(); usort( $dns, @@ -157,6 +151,38 @@ public function close(): void $this->bound = false; } + /** + * A seeded subtree outgrows the size limit a single search is capped at, so it is collected a page at a time. + * + * @return list + * @throws OperationException + */ + private function subtreeDns(): array + { + $paging = $this->client->paging( + Operations::search(Filters::present('objectClass')) + ->base($this->benchBaseDn) + ->useSubtreeScope(), + self::CLEANUP_PAGE_SIZE, + ); + + $dns = []; + + try { + while ($paging->hasEntries()) { + foreach ($paging->getEntries() as $entry) { + $dns[] = (string) $entry->getDn(); + } + } + } catch (OperationException $e) { + if ($e->getCode() !== ResultCode::NO_SUCH_OBJECT) { + throw $e; + } + } + + return $dns; + } + private function buildClient(): LdapClient { return new LdapClient( diff --git a/tests/performance/Config.php b/tests/performance/Config.php index e5455df6..c91331fc 100644 --- a/tests/performance/Config.php +++ b/tests/performance/Config.php @@ -110,6 +110,7 @@ public function __construct( public readonly int $maxSearchLookthrough = self::DEFAULT_MAX_SEARCH_LOOKTHROUGH, public readonly bool $journal = false, public readonly int $swooleWorkers = 0, + public readonly int $workerIdOffset = 0, ) { $this->assertEnum('backend', $backend, self::BACKENDS); $this->assertEnum('runner', $runner, self::RUNNERS); @@ -124,6 +125,7 @@ public function __construct( $this->assertNonNegative('seed-attributes', $seedAttributes); $this->assertNonNegative('max-search-lookthrough', $maxSearchLookthrough); $this->assertNonNegative('swoole-workers', $swooleWorkers); + $this->assertNonNegative('worker-id-offset', $workerIdOffset); if ($duration !== null) { $this->assertPositive('duration', $duration); diff --git a/tests/performance/Driver.php b/tests/performance/Driver.php index f2725ab5..c7eb623b 100644 --- a/tests/performance/Driver.php +++ b/tests/performance/Driver.php @@ -187,7 +187,7 @@ private function forkChildren(WorkloadMix $mix): array fclose($parentReady); fclose($parentGo); $this->runChild( - $i, + $this->config->workerIdOffset + $i, $mix, $childReady, $childGo, diff --git a/tests/profile/compare-ldap.sh b/tests/profile/compare-ldap.sh index cda470ec..6e529f11 100755 --- a/tests/profile/compare-ldap.sh +++ b/tests/profile/compare-ldap.sh @@ -72,8 +72,9 @@ resolve_svc() { local key="$1" p="$2" case "$key" in freedsx) + # Seeding and the write ops need an administrator, matching the root identity the other two sides bind as. declare -g "${p}_SVC=freedsx-server" "${p}_CONT=freedsx-profile-server" "${p}_PORT=10389" \ - "${p}_BIND=cn=user,dc=foo,dc=bar" "${p}_PW=12345" "${p}_BASE=dc=foo,dc=bar" \ + "${p}_BIND=cn=admin,dc=foo,dc=bar" "${p}_PW=12345" "${p}_BASE=dc=foo,dc=bar" \ "${p}_LABEL=FreeDSx/$STORAGE" "${p}_SETUP=none" ;; openldap) declare -g "${p}_SVC=openldap" "${p}_CONT=freedsx-profile-openldap" "${p}_PORT=389" \ diff --git a/tests/unit/Protocol/ServerProtocolHandler/AttributeProjectionTest.php b/tests/unit/Protocol/ServerProtocolHandler/AttributeProjectionTest.php index 4112cbf5..a206f1e6 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/AttributeProjectionTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/AttributeProjectionTest.php @@ -41,7 +41,7 @@ protected function setUp(): void ); } - public function test_empty_selection_returns_entry_unchanged(): void + public function test_empty_selection_returns_only_user_attributes(): void { $projection = AttributeProjection::forRequest( [], @@ -49,13 +49,13 @@ public function test_empty_selection_returns_entry_unchanged(): void $this->schema, ); - self::assertSame( - $this->entry, - $projection->project($this->entry), + self::assertEqualsCanonicalizing( + ['cn', 'sn', 'userPassword'], + $this->attributeNames($projection->project($this->entry)), ); } - public function test_star_selector_returns_every_attribute_already_on_the_entry(): void + public function test_star_selector_returns_only_user_attributes(): void { $projection = AttributeProjection::forRequest( [new Attribute('*')], @@ -66,11 +66,68 @@ public function test_star_selector_returns_every_attribute_already_on_the_entry( $projected = $projection->project($this->entry); self::assertEqualsCanonicalizing( - ['cn', 'sn', 'userPassword', 'createTimestamp', 'modifyTimestamp', 'entryUUID'], + ['cn', 'sn', 'userPassword'], $this->attributeNames($projected), ); } + /** + * RFC 4511 section 4.5.1.8 clause 2: "*" plus a named operational attribute returns both. + */ + public function test_star_selector_also_returns_an_operational_attribute_named_alongside_it(): void + { + $projection = AttributeProjection::forRequest( + [ + new Attribute('*'), + new Attribute('entryUUID'), + ], + false, + $this->schema, + ); + + self::assertEqualsCanonicalizing( + ['cn', 'sn', 'userPassword', 'entryUUID'], + $this->attributeNames($projection->project($this->entry)), + ); + } + + public function test_star_and_plus_together_return_every_attribute(): void + { + $projection = AttributeProjection::forRequest( + [ + new Attribute('*'), + new Attribute('+'), + ], + false, + $this->schema, + ); + + self::assertEqualsCanonicalizing( + ['cn', 'sn', 'userPassword', 'createTimestamp', 'modifyTimestamp', 'entryUUID'], + $this->attributeNames($projection->project($this->entry)), + ); + } + + public function test_an_attribute_the_schema_does_not_define_counts_as_a_user_attribute(): void + { + $entry = new Entry( + new Dn('cn=Alice,dc=example,dc=com'), + new Attribute('cn', 'Alice'), + new Attribute('someCustomAttr', 'value'), + ); + + $projection = AttributeProjection::forRequest( + [new Attribute('*')], + false, + $this->schema, + ); + + self::assertEqualsCanonicalizing( + ['cn', 'someCustomAttr'], + $this->attributeNames($projection->project($entry)), + ); + } + public function test_one_one_selector_strips_all_attributes(): void { $projection = AttributeProjection::forRequest( diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerMonitorHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerMonitorHandlerTest.php index 5e8a0bea..942151ce 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerMonitorHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerMonitorHandlerTest.php @@ -13,6 +13,7 @@ namespace Tests\Unit\FreeDSx\Ldap\Protocol\ServerProtocolHandler; +use FreeDSx\Ldap\Schema\SchemaResource; use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluator; use FreeDSx\Ldap\Server\Config\RunnerConfig; use FreeDSx\Ldap\Server\ServerRunner\RunnerMode; @@ -323,13 +324,34 @@ public function test_it_reports_in_flight_operations_under_a_coroutine_runner(): ); } - private function makeMessage(): LdapMessageRequest + /** + * The metrics are not schema-defined, so they count as user attributes and a bare read still returns them. + */ + public function test_it_narrows_the_entry_to_the_requested_attributes(): void + { + $stream = $this->subject->handleRequest( + $this->makeMessage('connectionsActive'), + $this->mockToken, + ); + $messages = [...$stream->messages]; + + /** @var SearchResultEntry $result */ + $result = $messages[0]->getResponse(); + + self::assertSame( + ['connectionsActive'], + array_keys($result->getEntry()->toArray()), + ); + } + + private function makeMessage(string ...$selectors): LdapMessageRequest { return new LdapMessageRequest( 1, (new SearchRequest(Filters::present('objectClass'))) ->base('cn=monitor') - ->useBaseScope(), + ->useBaseScope() + ->setAttributes(...$selectors), ); } @@ -349,9 +371,12 @@ private function handleAndCaptureEntry(?ServerMonitorHandler $subject = null): E private function responder(): GeneratedEntryResponder { + $schema = SchemaResource::Core->load(); + return new GeneratedEntryResponder( new RuleBasedAccessControl(), - new FilterEvaluator(), + new FilterEvaluator($schema), + $schema, ); } } diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerPagingHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerPagingHandlerTest.php index 8e7f105a..a0340d06 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerPagingHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerPagingHandlerTest.php @@ -316,6 +316,76 @@ public function test_it_throws_an_exception_if_the_paging_cookie_does_not_exist( ); } + public function test_it_rejects_a_negative_page_size_on_the_initial_request(): void + { + $message = $this->makeSearchMessage(size: -1); + + $this->mockBackend + ->expects(self::never()) + ->method('search'); + + self::expectExceptionObject(new OperationException( + 'The paged results size must not be negative.', + ResultCode::PROTOCOL_ERROR, + )); + + $this->subject->handleRequest( + $message, + $this->mockToken, + ); + } + + public function test_it_rejects_a_negative_page_size_on_a_subsequent_request(): void + { + $entry1 = Entry::create('cn=1,dc=foo,dc=bar', ['cn' => '1']); + $entry2 = Entry::create('cn=2,dc=foo,dc=bar', ['cn' => '2']); + $entry3 = Entry::create('cn=3,dc=foo,dc=bar', ['cn' => '3']); + + $this->mockBackend + ->method('search') + ->willReturn(new EntryStream($this->makeGenerator($entry1, $entry2, $entry3))); + + $subject = new ServerPagingHandler( + backend: $this->mockBackend, + filterEvaluator: $this->mockFilterEvaluator, + accessControl: $this->mockAccessControl, + requestHistory: $this->requestHistory, + schema: $this->schema, + limits: new SearchLimits(maxSearchPageSize: 1), + ); + $this->drive($subject, $this->makeSearchMessage(size: 1)); + + $cookie = $this->donePagingControl()->getCookie(); + self::assertNotSame('', $cookie); + + $this->sentMessages = []; + $pagingReq = $this->requestHistory->pagingRequest()->findByNextCookie($cookie); + + try { + $subject->handleRequest( + $this->makeSearchMessage( + size: -1, + cookie: $cookie, + searchRequest: $pagingReq->getSearchRequest(), + ), + $this->mockToken, + ); + self::fail('Expected the negative page size to be rejected.'); + } catch (OperationException $e) { + self::assertSame( + 'The paged results size must not be negative.', + $e->getMessage(), + ); + self::assertSame( + ResultCode::PROTOCOL_ERROR, + $e->getCode(), + ); + } + + // A negative size must not bypass the page bound and drain the rest of the result set. + self::assertSame([], $this->entryMessages()); + } + public function test_it_should_return_size_limit_exceeded_on_first_page_when_limit_is_hit(): void { $searchRequest = (new SearchRequest(Filters::raw('(foo=bar)'))) diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerRootDseHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerRootDseHandlerTest.php index 65a3a89b..d3f0232d 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerRootDseHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerRootDseHandlerTest.php @@ -57,10 +57,7 @@ public function test_it_should_send_back_a_RootDSE(): void $this->options->setDseVendorName('Foo'); $this->withStorageNamingContexts(['dc=Foo,dc=Bar']); - $search = new LdapMessageRequest( - 1, - (new SearchRequest(Filters::present('objectClass')))->base('')->useBaseScope(), - ); + $search = $this->rootDseSearch('*', '+'); $stream = $this->subject->handleRequest( $search, @@ -84,6 +81,7 @@ public function test_it_should_send_back_a_RootDSE(): void Control::OID_POST_READ, Control::OID_SUBTREE_DELETE, Control::OID_SUBENTRIES, + Control::OID_PWD_POLICY, ], 'supportedExtension' => [ ExtendedRequest::OID_WHOAMI, @@ -105,10 +103,7 @@ public function test_it_should_send_back_a_RootDSE(): void public function test_it_always_advertises_paging_and_password_modify(): void { - $search = new LdapMessageRequest( - 1, - (new SearchRequest(Filters::present('objectClass')))->base('')->useBaseScope(), - ); + $search = $this->rootDseSearch('*', '+'); $stream = $this->subject->handleRequest( $search, @@ -124,6 +119,23 @@ public function test_it_always_advertises_paging_and_password_modify(): void self::assertTrue($entry->get('supportedExtension')?->has(ExtendedRequest::OID_PWD_MODIFY) === true); } + public function test_it_always_advertises_the_password_policy_control(): void + { + $search = $this->rootDseSearch('*', '+'); + + $stream = $this->subject->handleRequest( + $search, + $this->mockToken, + ); + $messages = [...$stream->messages]; + + /** @var SearchResultEntry $result */ + $result = $messages[0]->getResponse(); + $entry = $result->getEntry(); + + self::assertTrue($entry->get('supportedControl')?->has(Control::OID_PWD_POLICY) === true); + } + public function test_it_advertises_the_sync_control_when_sync_is_enabled(): void { $this->subject = new ServerRootDseHandler( @@ -133,10 +145,7 @@ public function test_it_advertises_the_sync_control_when_sync_is_enabled(): void true, ); - $search = new LdapMessageRequest( - 1, - (new SearchRequest(Filters::present('objectClass')))->base('')->useBaseScope(), - ); + $search = $this->rootDseSearch('*', '+'); $stream = $this->subject->handleRequest( $search, @@ -155,10 +164,7 @@ public function test_it_should_include_supported_sasl_mechanisms_when_configured $this->options ->setSaslMechanisms(ServerOptions::SASL_PLAIN, ServerOptions::SASL_CRAM_MD5); - $search = new LdapMessageRequest( - 1, - (new SearchRequest(Filters::present('objectClass')))->base('')->useBaseScope(), - ); + $search = $this->rootDseSearch('*', '+'); $stream = $this->subject->handleRequest( $search, @@ -189,10 +195,7 @@ public function test_it_should_only_return_attribute_names_from_the_RootDSE_if_r $search = new LdapMessageRequest( 1, - (new SearchRequest(Filters::present('objectClass'))) - ->base('') - ->useBaseScope() - ->setAttributesOnly(true), + $this->rootDseRequest('*', '+')->setAttributesOnly(true), ); $stream = $this->subject->handleRequest( @@ -220,10 +223,7 @@ public function test_it_should_only_return_attribute_names_from_the_RootDSE_if_r public function test_it_advertises_subschema_subentry_in_rootdse(): void { - $search = new LdapMessageRequest( - 1, - (new SearchRequest(Filters::present('objectClass')))->base('')->useBaseScope(), - ); + $search = $this->rootDseSearch('*', '+'); $stream = $this->subject->handleRequest($search, $this->mockToken); $messages = [...$stream->messages]; @@ -241,10 +241,7 @@ public function test_it_uses_configured_subschema_entry_dn(): void $this->options->getSchemaConfig() ->setSubschemaEntry(new Dn('cn=schema,dc=example,dc=com')); - $search = new LdapMessageRequest( - 1, - (new SearchRequest(Filters::present('objectClass')))->base('')->useBaseScope(), - ); + $search = $this->rootDseSearch('*', '+'); $stream = $this->subject->handleRequest($search, $this->mockToken); $messages = [...$stream->messages]; @@ -257,28 +254,64 @@ public function test_it_uses_configured_subschema_entry_dn(): void self::assertTrue($attr->has('cn=schema,dc=example,dc=com')); } - public function test_it_should_only_return_specific_attributes_from_the_RootDSE_if_requested(): void + public function test_the_all_operational_selector_returns_every_root_dse_attribute_but_objectClass(): void { $this->options->setDseVendorName('Foo'); $this->withStorageNamingContexts(['dc=Foo,dc=Bar']); - $search = new LdapMessageRequest( - 1, - (new SearchRequest(Filters::present('objectClass'))) - ->base('') - ->useBaseScope() - ->setAttributes('namingcontexts'), + self::assertSame( + [ + 'namingContexts', + 'subschemaSubentry', + 'supportedControl', + 'supportedExtension', + 'supportedFeatures', + 'supportedLDAPVersion', + 'vendorName', + ], + $this->attributeNamesFor('+'), + ); + } + + /** + * Every RootDSE attribute but objectClass is operational, so neither selector reaches them. + */ + public function test_a_bare_read_returns_only_objectClass(): void + { + self::assertSame( + ['objectClass'], + $this->attributeNamesFor(), + ); + self::assertSame( + ['objectClass'], + $this->attributeNamesFor('*'), ); + } - # The reset below is needed, unfortunately, to properly test due to how the objects change... - # objectClass is built first and then dropped, which is what leaves namingContexts at its index. - $entry = Entry::create('', [ - 'objectClass' => 'top', - 'namingContexts' => 'dc=Foo,dc=Bar', - ]); - $entry->reset('objectClass'); - $entry->changes()->reset(); - $entry->get('namingContexts')?->equals(new Attribute('foo')); + public function test_the_no_attributes_selector_returns_an_empty_root_dse(): void + { + self::assertSame( + [], + $this->attributeNamesFor('1.1'), + ); + } + + public function test_an_explicitly_named_root_dse_attribute_matches_regardless_of_case(): void + { + self::assertSame( + ['supportedControl'], + $this->attributeNamesFor('SUPPORTEDCONTROL'), + ); + } + + public function test_it_should_only_return_specific_attributes_from_the_RootDSE_if_requested(): void + { + $this->options->setDseVendorName('Foo'); + $this->withStorageNamingContexts(['dc=Foo,dc=Bar']); + + $search = $this->rootDseSearch('namingcontexts'); + + $entry = Entry::create('', ['namingContexts' => 'dc=Foo,dc=Bar']); $stream = $this->subject->handleRequest( $search, @@ -297,6 +330,45 @@ public function test_it_should_only_return_specific_attributes_from_the_RootDSE_ ); } + /** + * A base-scoped search of the RootDSE, selecting the given attributes. + */ + private function rootDseRequest(string ...$selectors): SearchRequest + { + return (new SearchRequest(Filters::present('objectClass'))) + ->base('') + ->useBaseScope() + ->setAttributes(...$selectors); + } + + private function rootDseSearch(string ...$selectors): LdapMessageRequest + { + return new LdapMessageRequest( + 1, + $this->rootDseRequest(...$selectors), + ); + } + + /** + * @return list + */ + private function attributeNamesFor(string ...$selectors): array + { + $stream = $this->subject->handleRequest( + $this->rootDseSearch(...$selectors), + $this->mockToken, + ); + $messages = [...$stream->messages]; + + /** @var SearchResultEntry $result */ + $result = $messages[0]->getResponse(); + + return array_values(array_map( + static fn(Attribute $attribute): string => $attribute->getName(), + $result->getEntry()->getAttributes(), + )); + } + /** * @param list $dns */ @@ -322,6 +394,7 @@ private function responder(Schema $schema): GeneratedEntryResponder return new GeneratedEntryResponder( new RuleBasedAccessControl(), new FilterEvaluator($schema), + $schema, ); } } diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerSubschemaHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerSubschemaHandlerTest.php index 82168681..0c03ddc5 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerSubschemaHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerSubschemaHandlerTest.php @@ -88,7 +88,7 @@ public function test_it_uses_the_configured_subschema_entry_dn(): void public function test_it_returns_non_empty_attribute_types_in_rfc4512_format(): void { - $entry = $this->handleAndCaptureEntry(); + $entry = $this->handleAndCaptureEntry('+'); $values = $entry->get('attributeTypes')?->getValues() ?? []; self::assertGreaterThan(0, count($values)); @@ -97,7 +97,7 @@ public function test_it_returns_non_empty_attribute_types_in_rfc4512_format(): v public function test_it_returns_non_empty_object_classes(): void { - $entry = $this->handleAndCaptureEntry(); + $entry = $this->handleAndCaptureEntry('+'); self::assertGreaterThan( 0, @@ -107,7 +107,7 @@ public function test_it_returns_non_empty_object_classes(): void public function test_it_returns_non_empty_matching_rules(): void { - $entry = $this->handleAndCaptureEntry(); + $entry = $this->handleAndCaptureEntry('+'); self::assertGreaterThan( 0, @@ -117,7 +117,7 @@ public function test_it_returns_non_empty_matching_rules(): void public function test_it_returns_non_empty_ldap_syntaxes(): void { - $entry = $this->handleAndCaptureEntry(); + $entry = $this->handleAndCaptureEntry('+'); self::assertGreaterThan( 0, @@ -127,7 +127,7 @@ public function test_it_returns_non_empty_ldap_syntaxes(): void public function test_it_returns_non_empty_matching_rule_use(): void { - $entry = $this->handleAndCaptureEntry(); + $entry = $this->handleAndCaptureEntry('+'); self::assertGreaterThan( 0, @@ -135,25 +135,54 @@ public function test_it_returns_non_empty_matching_rule_use(): void ); } + /** + * The schema definitions are operational, so a client that did not ask for them gets the naming attributes only. + */ + public function test_a_bare_read_returns_only_the_naming_attributes(): void + { + foreach ([[], ['*']] as $selectors) { + $entry = $this->handleAndCaptureEntry(...$selectors); + + self::assertSame( + ['objectClass', 'cn'], + array_keys($entry->toArray()), + ); + } + } + + public function test_a_named_schema_attribute_is_returned_on_its_own(): void + { + $entry = $this->handleAndCaptureEntry('objectClasses'); + + self::assertSame( + ['objectClasses'], + array_keys($entry->toArray()), + ); + } + private function responder(Schema $schema): GeneratedEntryResponder { return new GeneratedEntryResponder( new RuleBasedAccessControl(), new FilterEvaluator($schema), + $schema, ); } - private function makeMessage(): LdapMessageRequest + private function makeMessage(string ...$selectors): LdapMessageRequest { return new LdapMessageRequest( 1, - (new SearchRequest(Filters::present('objectClass')))->base('cn=Subschema')->useBaseScope(), + (new SearchRequest(Filters::present('objectClass'))) + ->base($this->options->getSubschemaEntry()->toString()) + ->useBaseScope() + ->setAttributes(...$selectors), ); } - private function handleAndCaptureEntry(): Entry + private function handleAndCaptureEntry(string ...$selectors): Entry { - $stream = $this->subject->handleRequest($this->makeMessage(), $this->mockToken); + $stream = $this->subject->handleRequest($this->makeMessage(...$selectors), $this->mockToken); $messages = [...$stream->messages]; /** @var SearchResultEntry $result */ diff --git a/tests/unit/Schema/CoreSchemaResourceTest.php b/tests/unit/Schema/CoreSchemaResourceTest.php index d4b6b024..ac76123c 100644 --- a/tests/unit/Schema/CoreSchemaResourceTest.php +++ b/tests/unit/Schema/CoreSchemaResourceTest.php @@ -87,7 +87,7 @@ public function test_has_expected_matching_rule_count(): void public function test_has_expected_attribute_type_count(): void { self::assertCount( - 48, + 57, $this->schema->getAttributeTypes(), ); } @@ -333,6 +333,37 @@ public function test_subtree_specification_is_user_modifiable(): void self::assertFalse($attr->noUserModification); } + /** + * @param string $name + */ + #[\PHPUnit\Framework\Attributes\DataProvider('rootDseAttributes')] + public function test_root_dse_attributes_are_dsa_operational(string $name): void + { + $attr = $this->schema->getAttributeType($name); + + self::assertNotNull($attr); + self::assertSame( + AttributeUsage::DsaOperation, + $attr->usage, + ); + } + + /** + * @return iterable + */ + public static function rootDseAttributes(): iterable + { + yield 'altServer' => ['altServer']; + yield 'namingContexts' => ['namingContexts']; + yield 'supportedControl' => ['supportedControl']; + yield 'supportedExtension' => ['supportedExtension']; + yield 'supportedFeatures' => ['supportedFeatures']; + yield 'supportedLDAPVersion' => ['supportedLDAPVersion']; + yield 'supportedSASLMechanisms' => ['supportedSASLMechanisms']; + yield 'vendorName' => ['vendorName']; + yield 'vendorVersion' => ['vendorVersion']; + } + public function test_administrative_role_is_multi_valued_and_operational(): void { $attr = $this->schema->getAttributeType(AttributeTypeOid::NAME_ADMINISTRATIVE_ROLE); diff --git a/tests/unit/Server/Middleware/ServerControlRegistryTest.php b/tests/unit/Server/Middleware/ServerControlRegistryTest.php index 1e78a545..e17e6c0c 100644 --- a/tests/unit/Server/Middleware/ServerControlRegistryTest.php +++ b/tests/unit/Server/Middleware/ServerControlRegistryTest.php @@ -33,6 +33,7 @@ public function test_search_supports_expected_controls(): void [ Control::OID_PROXY_AUTHORIZATION, Control::OID_MANAGE_DSA_IT, + Control::OID_PWD_POLICY, Control::OID_SORTING, Control::OID_ASSERTION, Control::OID_SUBENTRIES, @@ -47,6 +48,7 @@ public function test_paging_supports_expected_controls(): void [ Control::OID_PROXY_AUTHORIZATION, Control::OID_MANAGE_DSA_IT, + Control::OID_PWD_POLICY, Control::OID_PAGING, Control::OID_SORTING, Control::OID_ASSERTION, @@ -62,6 +64,7 @@ public function test_dispatch_supports_expected_controls(): void [ Control::OID_PROXY_AUTHORIZATION, Control::OID_MANAGE_DSA_IT, + Control::OID_PWD_POLICY, Control::OID_RELAX_RULES, Control::OID_ASSERTION, Control::OID_PRE_READ, @@ -82,11 +85,24 @@ public function test_handlers_without_specific_controls_support_only_the_global_ [ Control::OID_PROXY_AUTHORIZATION, Control::OID_MANAGE_DSA_IT, + Control::OID_PWD_POLICY, ], $this->subject->supportedControlsFor($id), ); } + /** + * @param HandlerId $id + */ + #[\PHPUnit\Framework\Attributes\DataProvider('checkedHandlers')] + public function test_the_password_policy_control_is_supported_on_every_checked_handler(HandlerId $id): void + { + self::assertContains( + Control::OID_PWD_POLICY, + $this->subject->supportedControlsFor($id), + ); + } + /** * @return iterable */