diff --git a/src/FreeDSx/Ldap/Schema/Definition/ObjectClassOid.php b/src/FreeDSx/Ldap/Schema/Definition/ObjectClassOid.php index 972140f7..f08c30c4 100644 --- a/src/FreeDSx/Ldap/Schema/Definition/ObjectClassOid.php +++ b/src/FreeDSx/Ldap/Schema/Definition/ObjectClassOid.php @@ -24,6 +24,12 @@ final class ObjectClassOid public const DESC_TOP = 'top of the class hierarchy'; + public const OID_ALIAS = '2.5.6.1'; + + public const NAME_ALIAS = 'alias'; + + public const DESC_ALIAS = 'an alias entry pointing at another entry'; + public const OID_PERSON = '2.5.6.6'; public const NAME_PERSON = 'person'; diff --git a/src/FreeDSx/Ldap/Schema/StandardSchemaProvider.php b/src/FreeDSx/Ldap/Schema/StandardSchemaProvider.php index 396b7e07..45e037cb 100644 --- a/src/FreeDSx/Ldap/Schema/StandardSchemaProvider.php +++ b/src/FreeDSx/Ldap/Schema/StandardSchemaProvider.php @@ -579,6 +579,14 @@ private static function objectClasses(): array must: [AttributeTypeOid::NAME_OBJECT_CLASS], desc: ObjectClassOid::DESC_TOP, ), + new ObjectClass( + ObjectClassOid::OID_ALIAS, + [ObjectClassOid::NAME_ALIAS], + type: ObjectClassType::StructuralClass, + superClassOids: [ObjectClassOid::OID_TOP], + must: [AttributeTypeOid::NAME_ALIASED_OBJECT_NAME], + desc: ObjectClassOid::DESC_ALIAS, + ), new ObjectClass( ObjectClassOid::OID_PERSON, [ObjectClassOid::NAME_PERSON], diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/AliasDetector.php b/src/FreeDSx/Ldap/Server/Backend/Storage/AliasDetector.php new file mode 100644 index 00000000..bb60336d --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/AliasDetector.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage; + +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Schema\Definition\ObjectClassOid; + +use function strcasecmp; + +/** + * Recognizes alias entries (RFC 4512: an entry whose objectClass includes "alias"). + */ +final class AliasDetector +{ + private function __construct() {} + + public static function isAlias(Entry $entry): bool + { + $objectClass = $entry->get('objectClass'); + if ($objectClass === null) { + return false; + } + + foreach ($objectClass->getValues() as $value) { + if (strcasecmp($value, ObjectClassOid::NAME_ALIAS) === 0) { + return true; + } + } + + return false; + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/SearchStreamBuilder.php b/src/FreeDSx/Ldap/Server/Backend/Storage/SearchStreamBuilder.php index fb3241e7..e38fa224 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/SearchStreamBuilder.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/SearchStreamBuilder.php @@ -75,7 +75,14 @@ public function buildForList( SearchRequest $request, ?SearchLimits $effectiveLimits = null, ): EntryStream { - $generator = $this->wrapWithTimeLimitHandling($stream->entries); + $generator = $stream->entries; + + // In-search alias dereferencing is not supported. Decline rather than silently return the alias. + if ($this->derefsInSearch($request)) { + $generator = $this->wrapWithAliasDecline($generator); + } + + $generator = $this->wrapWithTimeLimitHandling($generator); if (!$stream->isPreFiltered) { $generator = $this->wrapWithFilterEvaluation( @@ -125,6 +132,33 @@ private function injectHasSubordinates(Entry $entry): Entry return $copy; } + private function derefsInSearch(SearchRequest $request): bool + { + $deref = $request->getDereferenceAliases(); + + return $deref === SearchRequest::DEREF_IN_SEARCHING + || $deref === SearchRequest::DEREF_ALWAYS; + } + + /** + * @param Generator $generator + * @return Generator + * @throws OperationException + */ + private function wrapWithAliasDecline(Generator $generator): Generator + { + foreach ($generator as $entry) { + if (AliasDetector::isAlias($entry)) { + throw new OperationException( + 'Alias dereferencing is not supported.', + ResultCode::ALIAS_DEREFERENCING_PROBLEM, + ); + } + + yield $entry; + } + } + /** * @param Generator $generator * @return Generator diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php b/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php index b6422a1d..36e449df 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php @@ -163,19 +163,19 @@ public function search( ); } + $this->assertBaseNotDeclinedAlias($entry, $request); + return $this->searchStream->buildForBaseObject( $entry, $request, ); } - // no chance to be confused with the RootDSE, which is handled special elsewhere. - if ($normBase->toString() !== '' && !$this->storage->exists($normBase)) { - $this->throwNoSuchObject( - $this->storage, - $baseDn, - ); - } + $this->assertSearchBaseExists( + $normBase, + $baseDn, + $request, + ); $subtree = $request->getScope() === SearchRequest::SCOPE_WHOLE_SUBTREE; $sortingControl = $controls->get(Control::OID_SORTING); @@ -407,6 +407,88 @@ public function move( }); } + /** + * Confirm the search base exists. + * + * When base-deref is requested, fetch it so an alias base can be declined. + * + * @throws OperationException + */ + private function assertSearchBaseExists( + Dn $normBase, + Dn $baseDn, + SearchRequest $request, + ): void { + // The RootDSE (empty base) is handled special elsewhere. + if ($normBase->toString() === '') { + return; + } + + if ($this->derefsBase($request)) { + $this->assertDereferenceableBaseExists($normBase, $baseDn, $request); + + return; + } + + if (!$this->storage->exists($normBase)) { + $this->throwNoSuchObject( + $this->storage, + $baseDn, + ); + } + } + + /** + * Base-deref path: fetch the base (so an alias base can be declined) and confirm it exists. + * + * @throws OperationException + */ + private function assertDereferenceableBaseExists( + Dn $normBase, + Dn $baseDn, + SearchRequest $request, + ): void { + $base = $this->storage->find($normBase); + if ($base === null) { + $this->throwNoSuchObject( + $this->storage, + $baseDn, + ); + } + + $this->assertBaseNotDeclinedAlias($base, $request); + } + + /** + * Decline (rather than silently ignore) when base dereferencing is requested for an alias base. + * + * @throws OperationException + */ + private function assertBaseNotDeclinedAlias( + Entry $base, + SearchRequest $request, + ): void { + if (!$this->derefsBase($request)) { + return; + } + if (!AliasDetector::isAlias($base)) { + return; + } + + throw new OperationException( + 'Alias dereferencing is not supported.', + ResultCode::ALIAS_DEREFERENCING_PROBLEM, + ); + } + + private function derefsBase(SearchRequest $request): bool + { + $deref = $request->getDereferenceAliases(); + + return $deref === SearchRequest::DEREF_FINDING_BASE_OBJECT + || $deref === SearchRequest::DEREF_ALWAYS; + } + /** * @return Dn[] base entry and all descendants, deepest-first so children precede their parents. */ diff --git a/tests/integration/Storage/LdapBackendStorageTest.php b/tests/integration/Storage/LdapBackendStorageTest.php index 721cf8ad..9429f8ac 100644 --- a/tests/integration/Storage/LdapBackendStorageTest.php +++ b/tests/integration/Storage/LdapBackendStorageTest.php @@ -18,6 +18,7 @@ use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\BindException; use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\Request\SearchRequest; use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Operations; use FreeDSx\Ldap\Search\Filters; @@ -542,6 +543,37 @@ public function testInexactSearchTripsLookthroughLimit(): void ); } + public function testSearchDeclinesAliasDereferencing(): void + { + $this->stopServer(); + $this->createServerProcess('tcp', static::storageExtraArgs()); + $this->authenticateUser(); + + $this->ldapClient()->create(Entry::fromArray('cn=ref,dc=foo,dc=bar', [ + 'objectClass' => ['top', 'alias', 'extensibleObject'], + 'cn' => 'ref', + 'aliasedObjectName' => 'cn=user,dc=foo,dc=bar', + ])); + + $neverRequest = Operations::search(Filters::equal('cn', 'ref')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope(); + + self::assertCount( + 1, + $this->ldapClient()->search($neverRequest), + ); + + $derefRequest = Operations::search(Filters::equal('cn', 'ref')) + ->base('dc=foo,dc=bar') + ->useSubtreeScope() + ->setDereferenceAliases(SearchRequest::DEREF_ALWAYS); + + $this->expectException(OperationException::class); + $this->expectExceptionCode(ResultCode::ALIAS_DEREFERENCING_PROBLEM); + $this->ldapClient()->search($derefRequest); + } + /** * Hook for subclasses to route the shared server through a different backend. * diff --git a/tests/unit/Schema/StandardSchemaProviderTest.php b/tests/unit/Schema/StandardSchemaProviderTest.php index db079a31..47ad5301 100644 --- a/tests/unit/Schema/StandardSchemaProviderTest.php +++ b/tests/unit/Schema/StandardSchemaProviderTest.php @@ -63,7 +63,7 @@ public function test_has_expected_attribute_type_count(): void public function test_has_expected_object_class_count(): void { self::assertCount( - 12, + 13, $this->schema->getObjectClasses(), ); } @@ -198,6 +198,17 @@ public function test_top_registered(): void ); } + public function test_alias_registered(): void + { + $alias = $this->schema->getObjectClass(ObjectClassOid::NAME_ALIAS); + + self::assertNotNull($alias); + self::assertContains( + AttributeTypeOid::NAME_ALIASED_OBJECT_NAME, + $alias->must, + ); + } + public function test_person_registered_by_oid(): void { self::assertNotNull( diff --git a/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php b/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php index 0ef725a2..1f2c50ee 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php @@ -721,6 +721,99 @@ public function test_search_does_not_trip_lookthrough_limit_within_cap(): void ); } + public function test_search_declines_alias_base_when_base_deref_requested(): void + { + $subject = new WritableStorageBackend(new InMemoryStorage([ + $this->base, + $this->alice, + $this->aliasEntry(), + ])); + + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::ALIAS_DEREFERENCING_PROBLEM); + + $request = (new SearchRequest(new PresentFilter('objectClass'))) + ->base('cn=ref,dc=example,dc=com') + ->useBaseScope() + ->setDereferenceAliases(SearchRequest::DEREF_FINDING_BASE_OBJECT); + iterator_to_array($subject->search($request)->entries); + } + + public function test_search_returns_alias_base_when_deref_never(): void + { + $subject = new WritableStorageBackend(new InMemoryStorage([ + $this->base, + $this->alice, + $this->aliasEntry(), + ])); + + $request = (new SearchRequest(new PresentFilter('objectClass'))) + ->base('cn=ref,dc=example,dc=com') + ->useBaseScope() + ->setDereferenceAliases(SearchRequest::DEREF_NEVER); + $entries = iterator_to_array($subject->search($request)->entries); + + self::assertCount(1, $entries); + self::assertSame( + 'cn=ref,dc=example,dc=com', + array_values($entries)[0]->getDn()->toString(), + ); + } + + public function test_search_declines_alias_in_subtree_when_in_search_deref_requested(): void + { + $subject = new WritableStorageBackend(new InMemoryStorage([ + $this->base, + $this->alice, + $this->aliasEntry(), + ])); + + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::ALIAS_DEREFERENCING_PROBLEM); + + $request = (new SearchRequest(new PresentFilter('objectClass'))) + ->base('dc=example,dc=com') + ->useSubtreeScope() + ->setDereferenceAliases(SearchRequest::DEREF_IN_SEARCHING); + iterator_to_array($subject->search($request)->entries); + } + + public function test_search_returns_alias_in_subtree_when_deref_never(): void + { + $subject = new WritableStorageBackend(new InMemoryStorage([ + $this->base, + $this->alice, + $this->aliasEntry(), + ])); + + $request = (new SearchRequest(new PresentFilter('objectClass'))) + ->base('dc=example,dc=com') + ->useSubtreeScope() + ->setDereferenceAliases(SearchRequest::DEREF_NEVER); + $dns = array_map( + static fn(Entry $entry): string => $entry->getDn()->toString(), + iterator_to_array($subject->search($request)->entries), + ); + + self::assertContains( + 'cn=ref,dc=example,dc=com', + $dns, + ); + } + + public function test_search_succeeds_with_deref_always_when_no_aliases_present(): void + { + $request = (new SearchRequest(new PresentFilter('objectClass'))) + ->base('dc=example,dc=com') + ->useSubtreeScope() + ->setDereferenceAliases(SearchRequest::DEREF_ALWAYS); + + self::assertCount( + 3, + iterator_to_array($this->subject->search($request)->entries), + ); + } + public function test_add_converts_storage_io_exception_to_unavailable_operation_exception(): void { $ioException = new StorageIoException('Unable to publish the storage update.'); @@ -1696,6 +1789,15 @@ static function (Dn $dn): void {}, ); } + private function aliasEntry(): Entry + { + return new Entry( + new Dn('cn=ref,dc=example,dc=com'), + new Attribute('objectClass', 'alias'), + new Attribute('aliasedObjectName', 'cn=Alice,dc=example,dc=com'), + ); + } + private function context(): WriteContext { return new WriteContext( diff --git a/tests/unit/Server/Backend/Storage/AliasDetectorTest.php b/tests/unit/Server/Backend/Storage/AliasDetectorTest.php new file mode 100644 index 00000000..db6b0c8c --- /dev/null +++ b/tests/unit/Server/Backend/Storage/AliasDetectorTest.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Ldap\Server\Backend\Storage; + +use FreeDSx\Ldap\Entry\Attribute; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Server\Backend\Storage\AliasDetector; +use PHPUnit\Framework\TestCase; + +final class AliasDetectorTest extends TestCase +{ + public function test_it_detects_an_alias_entry(): void + { + $entry = new Entry( + new Dn('cn=ref,dc=example,dc=com'), + new Attribute('objectClass', 'top', 'alias'), + new Attribute('aliasedObjectName', 'cn=real,dc=example,dc=com'), + ); + + self::assertTrue(AliasDetector::isAlias($entry)); + } + + public function test_it_matches_object_class_case_insensitively(): void + { + $entry = new Entry( + new Dn('cn=ref,dc=example,dc=com'), + new Attribute('objectClass', 'ALIAS'), + ); + + self::assertTrue(AliasDetector::isAlias($entry)); + } + + public function test_it_returns_false_for_a_non_alias_entry(): void + { + $entry = new Entry( + new Dn('cn=real,dc=example,dc=com'), + new Attribute('objectClass', 'inetOrgPerson'), + ); + + self::assertFalse(AliasDetector::isAlias($entry)); + } + + public function test_it_returns_false_when_object_class_is_absent(): void + { + $entry = new Entry(new Dn('cn=real,dc=example,dc=com')); + + self::assertFalse(AliasDetector::isAlias($entry)); + } +}