diff --git a/docs/Server/General-Usage.md b/docs/Server/General-Usage.md index 21e95b7a..feab3fd4 100644 --- a/docs/Server/General-Usage.md +++ b/docs/Server/General-Usage.md @@ -595,7 +595,8 @@ Seeding never overwrites an existing entry. To change one, modify it, or delete is logged, with a record of the counts once the import commits. Supplied operational attributes such as `entryUUID` are kept, as the -[Relax Rules control](Schema.md#validation-mode) allows a client's Add. +[Relax Rules control](Schema.md#validation-mode) allows a client's Add. Attributes the server derives, such as +`hasSubordinates` or a back-link like `memberOf`, are refused with `constraintViolation` even when validation is ignored. `seed()` accepts only content records (entries without `changetype:`) and requires depth-first input (parents first, then children entries). LDIF produced by `dump()` is already in this order. diff --git a/docs/Server/Schema.md b/docs/Server/Schema.md index d1fdd10b..2b5ec7b1 100644 --- a/docs/Server/Schema.md +++ b/docs/Server/Schema.md @@ -162,11 +162,13 @@ existing entry. | Changing the structural object class | Waived | Waived | | Writing `createTimestamp`, `modifyTimestamp`, `creatorsName` or `modifiersName` | Waived | Waived | | Writing `entryUUID` on Add | Waived | Waived | -| Writing `entryUUID` on Modify, or any other `NO-USER-MODIFICATION` attribute | Refused | Waived | +| Changing `entryUUID`, writing a derived attribute (`entryDN`, `hasSubordinates`, `subschemaSubentry`) or a back-link such as `memberOf` | Refused | Refused | +| Writing any other `NO-USER-MODIFICATION` attribute | Refused | Waived | | Required and allowed attributes, a structural object class, undefined types, undefined object classes, single-valued types, naming attributes | Refused | Waived | | Attribute syntax, the value limit, equivalent values, an attribute description given twice | Refused | Refused | -Each violation is logged and the first that is not waived decides the result code. +Each violation is logged and the first that is not waived decides the result code. An entry's identity and the values +the server derives for it are refused with `constraintViolation` in every mode, `Off` included. A value a relaxed write supplies for one of these attributes is stored as given. A timestamp may not be in the future, and `createTimestamp` may not be later than `modifyTimestamp`. A supplied `entryUUID` may not be one another entry holds. diff --git a/src/FreeDSx/Ldap/Container/Provider/DirectoryServerContainerProvider.php b/src/FreeDSx/Ldap/Container/Provider/DirectoryServerContainerProvider.php index f0bb448c..fe679dd6 100644 --- a/src/FreeDSx/Ldap/Container/Provider/DirectoryServerContainerProvider.php +++ b/src/FreeDSx/Ldap/Container/Provider/DirectoryServerContainerProvider.php @@ -202,6 +202,7 @@ public function factories(): array ), SchemaViolationGate::class => static fn(Container $c): SchemaViolationGate => new SchemaViolationGate( $c->get(SchemaValidator::class), + $c->get(LinkedAttributes::class), ), RdnAttributeValues::class => static fn(Container $c): RdnAttributeValues => new RdnAttributeValues( $c->get(EqualityComparatorResolver::class), diff --git a/src/FreeDSx/Ldap/Container/Provider/HandlerContainerProvider.php b/src/FreeDSx/Ldap/Container/Provider/HandlerContainerProvider.php index ad90bb69..8c617c11 100644 --- a/src/FreeDSx/Ldap/Container/Provider/HandlerContainerProvider.php +++ b/src/FreeDSx/Ldap/Container/Provider/HandlerContainerProvider.php @@ -240,6 +240,7 @@ private function makeDispatchHandler( readEntryControlHandler: new ReadEntryControlHandler( $container->get(ServerOptions::class)->getSchema(), $container->get(AccessControlInterface::class), + $container->get(LinkedAttributes::class), ), ); } diff --git a/src/FreeDSx/Ldap/Container/Provider/PdoStorageContainerProvider.php b/src/FreeDSx/Ldap/Container/Provider/PdoStorageContainerProvider.php index d020d641..f4331691 100644 --- a/src/FreeDSx/Ldap/Container/Provider/PdoStorageContainerProvider.php +++ b/src/FreeDSx/Ldap/Container/Provider/PdoStorageContainerProvider.php @@ -296,12 +296,13 @@ private function makeEntryLinks(Container $container): EntryLinks $dialect, $connection, LinkDirection::Forward, + $linked, ), new LinkSpanReader( $dialect, $connection, LinkDirection::Backward, - $linked->backlinks(), + $linked, ), ); } diff --git a/src/FreeDSx/Ldap/Entry/Attribute.php b/src/FreeDSx/Ldap/Entry/Attribute.php index b3ff63a4..0b0b0e58 100644 --- a/src/FreeDSx/Ldap/Entry/Attribute.php +++ b/src/FreeDSx/Ldap/Entry/Attribute.php @@ -15,6 +15,7 @@ use ArrayIterator; use Countable; +use FreeDSx\Ldap\Schema\Text; use IteratorAggregate; use Stringable; use Traversable; @@ -219,7 +220,7 @@ public static function isValidDescription(string $description): bool public static function isStorableType(string $type): bool { return strlen($type) <= self::MAX_TYPE_LENGTH - && preg_match('/^[\x20-\x7E]*$/D', $type) === 1; + && Text::isPrintableAscii($type); } /** diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ReadEntryControlHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ReadEntryControlHandler.php index a9204371..7e433e69 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ReadEntryControlHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ReadEntryControlHandler.php @@ -22,6 +22,7 @@ use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Schema\Schema; use FreeDSx\Ldap\Server\AccessControl\AccessControlInterface; +use FreeDSx\Ldap\Server\Backend\Storage\Schema\LinkedAttributes; use FreeDSx\Ldap\Server\Token\TokenInterface; /** @@ -42,6 +43,7 @@ public function __construct( private Schema $schema, private AccessControlInterface $accessControl, + private LinkedAttributes $linked, ) {} /** @@ -95,6 +97,8 @@ private function readEntry( if (!$control instanceof ReadEntryControl || $entry === null) { return null; } + // RFC 4527: the entry is returned as a search would return it, with linked attributes under their schema names. + $entry = $this->linked->withSchemaNames($entry); $scope = $this->accessControl->readScope($token); if (!$scope->isEntryVisible($entry)) { diff --git a/src/FreeDSx/Ldap/Schema/Text.php b/src/FreeDSx/Ldap/Schema/Text.php index 08c5e01d..520fe5a3 100644 --- a/src/FreeDSx/Ldap/Schema/Text.php +++ b/src/FreeDSx/Ldap/Schema/Text.php @@ -28,6 +28,14 @@ public static function isAscii(string $value): bool return preg_match('/[^\x00-\x7F]/', $value) === 0; } + /** + * Whether the value contains only printable ASCII bytes, without control characters. + */ + public static function isPrintableAscii(string $value): bool + { + return preg_match('/[^\x20-\x7E]/', $value) === 0; + } + /** * Whether the value is well-formed UTF-8. */ diff --git a/src/FreeDSx/Ldap/Schema/Validation/Check/NoUserModificationCheck.php b/src/FreeDSx/Ldap/Schema/Validation/Check/NoUserModificationCheck.php index 50ac53ff..b5e88888 100644 --- a/src/FreeDSx/Ldap/Schema/Validation/Check/NoUserModificationCheck.php +++ b/src/FreeDSx/Ldap/Schema/Validation/Check/NoUserModificationCheck.php @@ -13,9 +13,6 @@ namespace FreeDSx\Ldap\Schema\Validation\Check; -use FreeDSx\Ldap\Entry\Attribute; -use FreeDSx\Ldap\Entry\Change; -use FreeDSx\Ldap\Entry\Rdn; use FreeDSx\Ldap\Operation\OperationType; use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Schema\Definition\AttributeTypeOid; @@ -25,7 +22,6 @@ use FreeDSx\Ldap\Schema\Validation\SchemaRule; use FreeDSx\Ldap\Schema\Validation\SchemaWrite; -use function array_map; use function in_array; use function sprintf; @@ -109,7 +105,7 @@ private function firstSuppliedUnder( $wantSettable = $rule === SchemaRule::SettableOperationalAttribute; $settable = $this->settableOn($write->operation); - foreach ($this->suppliedNames($write) as $name) { + foreach ($write->suppliedNames() as $name) { $attrType = $this->schema->getAttributeType($name); if ($attrType === null || !$attrType->noUserModification) { @@ -125,28 +121,6 @@ private function firstSuppliedUnder( return null; } - /** - * @return array - */ - private function suppliedNames(SchemaWrite $write): array - { - return match ($write->operation) { - OperationType::Add => array_map( - static fn(Attribute $attribute): string => $attribute->getName(), - $write->entry->getAttributes(), - ), - OperationType::Modify => array_map( - static fn(Change $change): string => $change->getAttribute()->getName(), - $write->changes, - ), - // A rename supplies only the values its new RDN puts on the entry. - default => array_map( - static fn(Rdn $component): string => $component->getName(), - $write->entry->getDn()->getRdn()->getAll(), - ), - }; - } - /** * @return list */ diff --git a/src/FreeDSx/Ldap/Schema/Validation/SchemaWrite.php b/src/FreeDSx/Ldap/Schema/Validation/SchemaWrite.php index 29e066e1..6ae7f4fc 100644 --- a/src/FreeDSx/Ldap/Schema/Validation/SchemaWrite.php +++ b/src/FreeDSx/Ldap/Schema/Validation/SchemaWrite.php @@ -13,10 +13,15 @@ namespace FreeDSx\Ldap\Schema\Validation; +use FreeDSx\Ldap\Entry\Attribute; use FreeDSx\Ldap\Entry\Change; use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Entry\Rdn; use FreeDSx\Ldap\Operation\OperationType; +use function array_map; +use function array_values; + /** * The entry a write leaves behind, and what the client supplied to get there. * @@ -85,4 +90,27 @@ public function namesEntry(): bool { return $this->operation !== OperationType::Modify; } + + /** + * The names the client supplied: the entry's on an Add, the changes' on a Modify, the new RDN's on a rename. + * + * @return list + */ + public function suppliedNames(): array + { + return match ($this->operation) { + OperationType::Add => array_map( + static fn(Attribute $attribute): string => $attribute->getName(), + array_values($this->entry->getAttributes()), + ), + OperationType::Modify => array_map( + static fn(Change $change): string => $change->getAttribute()->getName(), + array_values($this->changes), + ), + default => array_map( + static fn(Rdn $component): string => $component->getName(), + array_values($this->entry->getDn()->getRdn()->getAll()), + ), + }; + } } diff --git a/src/FreeDSx/Ldap/Server/AccessControl/OperationTargetDn.php b/src/FreeDSx/Ldap/Server/AccessControl/OperationTargetDn.php index cfe1b84b..8f14c28b 100644 --- a/src/FreeDSx/Ldap/Server/AccessControl/OperationTargetDn.php +++ b/src/FreeDSx/Ldap/Server/AccessControl/OperationTargetDn.php @@ -20,6 +20,10 @@ use FreeDSx\Ldap\Operation\Request\ModifyDnRequest; use FreeDSx\Ldap\Operation\Request\ModifyRequest; use FreeDSx\Ldap\Operation\Request\RequestInterface; +use FreeDSx\Ldap\Operation\Request\SearchRequest; + +use function array_filter; +use function array_values; /** * Resolves the primary target DN an operation request acts on. @@ -40,6 +44,24 @@ public static function of(RequestInterface $request): ?Dn }; } + /** + * @return list + */ + public static function named(RequestInterface $request): array + { + $dns = [ + $request instanceof SearchRequest + ? $request->getBaseDn() + : self::of($request), + ]; + + if ($request instanceof ModifyDnRequest) { + $dns[] = $request->getNewParentDn(); + } + + return array_values(array_filter($dns)); + } + /** * Where a rename leaves the entry, derived the same way the backend derives it. */ diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/InMemoryStorage.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/InMemoryStorage.php index 75d9c262..bf23368c 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/InMemoryStorage.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/InMemoryStorage.php @@ -461,19 +461,26 @@ private static function withoutLinkValues( } /** - * The entry carrying the linked values the one it replaces held. + * The entry carrying the linked values another holds, under the names a read from storage returns them by. */ - private function withStoredLinks( + private function withLinksOf( Entry $entry, - ?Entry $previous, + ?Entry $holder, ): Entry { - if ($previous === null) { + $held = $holder === null + ? [] + : $this->linkedAttributes->valuesOf($holder); + + if ($held === []) { return $entry; } $restored = $entry->makeCopy(); - foreach ($this->linkedAttributes->valuesOf($previous) as $name => $values) { - $restored->set($name, ...$values); + foreach ($held as $name => $values) { + $restored->set( + $this->linkedAttributes->primaryName($name), + ...$values, + ); } return $restored; @@ -488,14 +495,15 @@ private function withLinks( ?Entry $previous, ): Entry { if ($links->isUntouched()) { - return $this->withStoredLinks( + return $this->withLinksOf( $entry, $previous, ); } + // Written whole, the entry holds every value it links, though under the client's spelling. if ($links->isEmpty()) { - return $entry; + return $this->linkedAttributes->withSchemaNames($entry); } $updated = $entry->makeCopy(); @@ -510,7 +518,10 @@ private function withLinks( $values === [] ? $updated->reset($name) - : $updated->set($name, ...$values); + : $updated->set( + $this->linkedAttributes->primaryName($name), + ...$values, + ); } return $updated; diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/EntryLinks.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/EntryLinks.php index b7c4914e..99ce0ea7 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/EntryLinks.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/EntryLinks.php @@ -262,7 +262,7 @@ private function sliced( } $links[$window->nameFor( - $name, + $this->declared->primaryName($name), count($values), $more, )] = $values; diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/EntryRowCodec.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/EntryRowCodec.php index 47e42bb8..3803cb0e 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/EntryRowCodec.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/EntryRowCodec.php @@ -52,7 +52,7 @@ public function encode(Entry $entry): string /** * @param array $row * @param array|null $allowed Base names to materialize, or null for all. - * @param array> $links Already fetched link values, keyed by the lowercased name each is returned under. + * @param array> $links Already fetched link values, keyed by the name each is returned under. * * @throws StorageIoException when the row's attribute blob cannot be decoded */ diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/Link/LinkSpanReader.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/Link/LinkSpanReader.php index f0abefe9..8bc4f60d 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/Link/LinkSpanReader.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/Link/LinkSpanReader.php @@ -20,6 +20,7 @@ use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\Statement\PooledStatement; use FreeDSx\Ldap\Server\Backend\Storage\Link\LinkDirection; use FreeDSx\Ldap\Server\Backend\Storage\Schema\Backlinks; +use FreeDSx\Ldap\Server\Backend\Storage\Schema\LinkedAttributes; use Generator; use function array_map; @@ -48,18 +49,27 @@ private string $attributeFrom; + private Backlinks $backlinks; + /** - * @param Backlinks $namedAs What each stored attribute is read back under, which reading forwards is nothing. + * @var array> The names each stored attribute's values are returned under. */ + private array $returnedAs; + public function __construct( private PdoLinkReadDialectInterface&PdoBacklinkReadDialectInterface $dialect, private PdoConnection $connection, private LinkDirection $direction, - private Backlinks $namedAs = new Backlinks(), + LinkedAttributes $linked, ) { // Resolved here rather than in a method of its own because PhpStan doesn't support that :/ $backward = $direction === LinkDirection::Backward; - $names = $namedAs->linkedNames(); + $this->backlinks = $linked->backlinks(); + $this->returnedAs = self::returnedAs( + $direction, + $linked, + ); + $names = $this->backlinks->linkedNames(); $this->attribute = $backward ? $dialect->queryBacklinksForAttribute() @@ -86,7 +96,7 @@ public function __construct( public function isIdle(): bool { return $this->direction === LinkDirection::Backward - && $this->namedAs->isEmpty(); + && $this->backlinks->isEmpty(); } /** @@ -179,7 +189,7 @@ private function spanExcept(int $count): string return match ($this->direction) { LinkDirection::Forward => $this->dialect->queryLinksForRangeExcept($count), LinkDirection::Backward => $this->dialect->queryBacklinksForRangeExcept( - $this->namedAs->linkedNames(), + $this->backlinks->linkedNames(), $count, ), }; @@ -269,8 +279,7 @@ private function grouped( $entryId = $this->intColumn($row['key_entry_id'] ?? null); $dn = $this->stringColumn($row['dn'] ?? null); - // Reading forwards the stored name is the one it is read under; reading backwards, every back-link's. - foreach ($this->namedAs->reversing($stored) ?: [$stored] as $name) { + foreach ($this->returnedAs[$stored] ?? [$stored] as $name) { $links[$entryId][$name][] = $dn; } } @@ -314,6 +323,31 @@ private function bounded( return $bounded; } + /** + * @return array> + */ + private static function returnedAs( + LinkDirection $direction, + LinkedAttributes $linked, + ): array { + $returnedAs = []; + $backlinks = $linked->backlinks(); + + if ($direction === LinkDirection::Backward) { + foreach ($backlinks->linkedNames() as $stored) { + $returnedAs[$stored] = $backlinks->reversing($stored); + } + + return $returnedAs; + } + + foreach ($linked->names() as $stored) { + $returnedAs[$stored] = [$linked->primaryName($stored)]; + } + + return $returnedAs; + } + /** * @return Generator> */ diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Support/ArrayEntryStorageTrait.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Support/ArrayEntryStorageTrait.php index ebca503a..023c399f 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Support/ArrayEntryStorageTrait.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Support/ArrayEntryStorageTrait.php @@ -138,7 +138,7 @@ private function sliced( $sliced->set( $window->nameFor( - $name, + $this->linkedAttributes->primaryName($name), count($slice), count($values) > $window->first + count($slice), ), diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Schema/Backlinks.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Schema/Backlinks.php index f63fa3da..0f1049b2 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Schema/Backlinks.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Schema/Backlinks.php @@ -16,12 +16,13 @@ use FreeDSx\Ldap\Entry\Attribute; use function array_keys; +use function array_map; use function array_unique; use function array_values; use function in_array; /** - * Which attributes reverse which linked ones. + * Which attributes reverse which linked ones, looked up by lowercased name and read back under the schema's spelling. * * @internal * @@ -30,8 +31,8 @@ final readonly class Backlinks { /** - * @param array $reverses Every back-link name, aliases included, to the linked name it reverses. - * @param array $read The name each back-link is read under, to the linked name it reverses. + * @param array $reverses Each lowercased back-link name or alias, to the linked name it reverses. + * @param array $read The schema name each back-link is read under, to the linked name it reverses. */ public function __construct( private array $reverses = [], @@ -84,20 +85,23 @@ public function linkedNames(): array */ public function names(): array { - return array_keys($this->read); + return array_map( + Attribute::normalizeName(...), + array_keys($this->read), + ); } /** * Only the back-links among those named. * - * @param list $wanted + * @param list $wanted Lowercased names. */ public function only(array $wanted): self { $read = []; foreach ($this->read as $backlink => $reverses) { - if (in_array($backlink, $wanted, true)) { + if (in_array(Attribute::normalizeName($backlink), $wanted, true)) { $read[$backlink] = $reverses; } } diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Schema/LinkedAttributes.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Schema/LinkedAttributes.php index 9d3ab0ba..706ec22c 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Schema/LinkedAttributes.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Schema/LinkedAttributes.php @@ -88,6 +88,14 @@ public function backlinks(): Backlinks return $this->reversed ??= $this->resolveBacklinks(); } + /** + * The name a linked attribute or back-link is returned under. This is its type's primary name. + */ + public function primaryName(string $name): string + { + return $this->schema->getAttributeType($name)?->primaryName() ?? $name; + } + /** * Whether values live in the link table rather than on the entry. */ @@ -188,6 +196,40 @@ public function valuesOf(Entry $entry): array return $values; } + /** + * The entry with its linked attributes under their schema names, copied only when one is spelled otherwise. + */ + public function withSchemaNames(Entry $entry): Entry + { + foreach ($entry->getAttributes() as $attribute) { + if ($this->isSpelledOtherwise($attribute)) { + return $this->respelled($entry); + } + } + + return $entry; + } + + private function isSpelledOtherwise(Attribute $attribute): bool + { + return $this->links($attribute) + && $attribute->getName() !== $this->primaryName($attribute->getName()); + } + + private function respelled(Entry $entry): Entry + { + $respelled = $entry->makeCopy(); + + foreach ($this->valuesOf($entry) as $name => $values) { + $respelled->set( + $this->primaryName($name), + ...$values, + ); + } + + return $respelled; + } + private function resolveBacklinks(): Backlinks { $reverses = []; @@ -205,7 +247,7 @@ private function resolveBacklinks(): Backlinks } // Values come back under one name per type, since repeating them under every alias would only duplicate. - $read[Attribute::normalizeName($type->primaryName())] = $linked; + $read[$type->primaryName()] = $linked; } return new Backlinks( diff --git a/src/FreeDSx/Ldap/Server/Backend/Write/OperationalAttributeGenerator.php b/src/FreeDSx/Ldap/Server/Backend/Write/OperationalAttributeGenerator.php index 9bc059de..9f6942a7 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Write/OperationalAttributeGenerator.php +++ b/src/FreeDSx/Ldap/Server/Backend/Write/OperationalAttributeGenerator.php @@ -143,13 +143,13 @@ public function applyForModify( true, ); - if (!isset($supplied[strtolower(AttributeTypeOid::NAME_MODIFY_TIMESTAMP)])) { + if (!self::keepsSupplied($entry, $supplied, AttributeTypeOid::NAME_MODIFY_TIMESTAMP)) { $entry->set( AttributeTypeOid::NAME_MODIFY_TIMESTAMP, $this->generateTimestamp(), ); } - if (!isset($supplied[strtolower(AttributeTypeOid::NAME_MODIFIERS_NAME)])) { + if (!self::keepsSupplied($entry, $supplied, AttributeTypeOid::NAME_MODIFIERS_NAME)) { $entry->set( AttributeTypeOid::NAME_MODIFIERS_NAME, $context->getBoundDn() ?? '', @@ -165,6 +165,20 @@ public function applyForModify( } } + /** + * The value a change supplies stands only while the updated entry still holds it. + * + * @param array $supplied + */ + private static function keepsSupplied( + Entry $entry, + array $supplied, + string $name, + ): bool { + return isset($supplied[strtolower($name)]) + && $entry->has($name); + } + /** * RFC 4512 3.3: the superclasses of every named class are added implicitly. */ diff --git a/src/FreeDSx/Ldap/Server/Backend/Write/Schema/SchemaViolationGate.php b/src/FreeDSx/Ldap/Server/Backend/Write/Schema/SchemaViolationGate.php index b6d6ef10..6709cfea 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Write/Schema/SchemaViolationGate.php +++ b/src/FreeDSx/Ldap/Server/Backend/Write/Schema/SchemaViolationGate.php @@ -19,11 +19,16 @@ use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\Exception\SchemaRuleException; use FreeDSx\Ldap\Exception\SchemaValidationException; +use FreeDSx\Ldap\Operation\OperationType; use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Schema\Definition\AttributeTypeOid; use FreeDSx\Ldap\Schema\SchemaValidationMode; +use FreeDSx\Ldap\Schema\Text; use FreeDSx\Ldap\Schema\Validation\RuleViolation; use FreeDSx\Ldap\Schema\Validation\SchemaValidator; use FreeDSx\Ldap\Schema\Validation\SchemaWrite; +use FreeDSx\Ldap\Server\Backend\Storage\Derived\DerivedAttributeTrait; +use FreeDSx\Ldap\Server\Backend\Storage\Schema\LinkedAttributes; use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; use FreeDSx\Ldap\Server\Backend\Write\WriteContext; @@ -34,7 +39,12 @@ */ final readonly class SchemaViolationGate { - public function __construct(private SchemaValidator $validator) {} + use DerivedAttributeTrait; + + public function __construct( + private SchemaValidator $validator, + private LinkedAttributes $linked, + ) {} /** * @throws OperationException @@ -94,6 +104,7 @@ private function assertAllowed( WriteContext $context, ): void { $this->assertTypesStorable($write->entry); + $this->assertServerOwnedUntouched($write); try { $this->validator->validate($write); @@ -111,30 +122,77 @@ private function assertAllowed( private function assertTypesStorable(Entry $entry): void { foreach ($entry->getAttributes() as $attribute) { - $type = $attribute->getName(); - - // RFC 4512 2.5: a description always names a type, whatever the validation mode allows. - if ($type === '') { - throw new OperationException( - 'The attribute description names no attribute type.', - ResultCode::UNDEFINED_ATTRIBUTE_TYPE, - ); - } - if (Attribute::isStorableType($type)) { + $this->assertTypeStorable($attribute->getName()); + } + } + + /** + * RFC 4512 2.5: a description names a type, and only printable ASCII spells one, whatever the mode allows. + * + * @throws OperationException + */ + private function assertTypeStorable(string $type): void + { + if ($type === '' || !Text::isPrintableAscii($type)) { + throw new OperationException( + 'The attribute description names no type a schema can define.', + ResultCode::UNDEFINED_ATTRIBUTE_TYPE, + ); + } + if (strlen($type) <= Attribute::MAX_TYPE_LENGTH) { + return; + } + + throw new OperationException( + sprintf( + 'The attribute type is %d bytes, beyond the %d the directory stores.', + strlen($type), + Attribute::MAX_TYPE_LENGTH, + ), + ResultCode::ADMIN_LIMIT_EXCEEDED, + ); + } + + /** + * @throws OperationException + */ + private function assertServerOwnedUntouched(SchemaWrite $write): void + { + foreach ($write->suppliedNames() as $name) { + if (!$this->isServerOwned($name, $write->operation)) { continue; } throw new OperationException( sprintf( - 'The attribute type is %d bytes, beyond the %d the directory stores, or is not ASCII.', - strlen($type), - Attribute::MAX_TYPE_LENGTH, + 'The attribute "%s" is maintained by the server and cannot be written.', + $name, ), - ResultCode::ADMIN_LIMIT_EXCEEDED, + ResultCode::CONSTRAINT_VIOLATION, ); } } + /** + * An Add may still supply its entryUUID, since that is where an entry's identity begins. + */ + private function isServerOwned( + string $name, + OperationType $operation, + ): bool { + if ($operation !== OperationType::Add && self::namesEntryUuid($name)) { + return true; + } + + return self::describesAnyDerivedAttribute($name) + || $this->linked->linkedBy($name) !== null; + } + + private static function namesEntryUuid(string $name): bool + { + return Attribute::normalizeName($name) === strtolower(AttributeTypeOid::NAME_ENTRY_UUID); + } + /** * @throws SchemaRuleException */ diff --git a/src/FreeDSx/Ldap/Server/Middleware/ResponseWriterMiddleware.php b/src/FreeDSx/Ldap/Server/Middleware/ResponseWriterMiddleware.php index a6cbc388..27a783af 100644 --- a/src/FreeDSx/Ldap/Server/Middleware/ResponseWriterMiddleware.php +++ b/src/FreeDSx/Ldap/Server/Middleware/ResponseWriterMiddleware.php @@ -13,12 +13,15 @@ namespace FreeDSx\Ldap\Server\Middleware; +use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\Request\RequestInterface; use FreeDSx\Ldap\Protocol\Factory\ResponseFactory; use FreeDSx\Ldap\Protocol\Queue\Response\ResponseStream; use FreeDSx\Ldap\Protocol\Queue\Response\ResponseWriter; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\MatchedDnAccessFilterTrait; use FreeDSx\Ldap\Server\AccessControl\AccessControlInterface; +use FreeDSx\Ldap\Server\AccessControl\OperationTargetDn; use FreeDSx\Ldap\Server\Backend\ReadBackendInterface; use FreeDSx\Ldap\Server\Middleware\Pipeline\MiddlewareHandlerInterface; use FreeDSx\Ldap\Server\Middleware\Pipeline\MiddlewareInterface; @@ -84,7 +87,10 @@ private function errorStream( $exception->getCode(), $exception->getMessage(), $this->filterMatchedDn( - $exception->getMatchedDn(), + self::asProvided( + $exception->getMatchedDn(), + $context->message->getRequest(), + ), $context->tokenOrFail(), $this->backend, $this->accessControl, @@ -98,4 +104,46 @@ private function errorStream( ), ); } + + /** + * RFC 4511 4.1.9: the matchedDN is a truncated form of the provided name, in the spelling the request used. + */ + private static function asProvided( + ?Dn $matchedDn, + RequestInterface $request, + ): ?Dn { + if ($matchedDn === null) { + return null; + } + $matched = $matchedDn->normalizedString(); + + foreach (OperationTargetDn::named($request) as $provided) { + $truncated = self::truncatedTo( + $provided, + $matched, + ); + + if ($truncated !== null) { + return $truncated; + } + } + + return $matchedDn; + } + + /** + * The provided DN, or the ancestor of it, that names the same entry as the matched one. + */ + private static function truncatedTo( + Dn $provided, + string $matched, + ): ?Dn { + for ($candidate = $provided; $candidate !== null; $candidate = $candidate->getParent()) { + if ($candidate->normalizedString() === $matched) { + return $candidate; + } + } + + return null; + } } diff --git a/src/FreeDSx/Ldap/Server/Middleware/TargetDnSyntaxMiddleware.php b/src/FreeDSx/Ldap/Server/Middleware/TargetDnSyntaxMiddleware.php index 323779cb..49524795 100644 --- a/src/FreeDSx/Ldap/Server/Middleware/TargetDnSyntaxMiddleware.php +++ b/src/FreeDSx/Ldap/Server/Middleware/TargetDnSyntaxMiddleware.php @@ -15,9 +15,6 @@ use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Exception\OperationException; -use FreeDSx\Ldap\Operation\Request\ModifyDnRequest; -use FreeDSx\Ldap\Operation\Request\RequestInterface; -use FreeDSx\Ldap\Operation\Request\SearchRequest; use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\Queue\Response\ResponseStream; use FreeDSx\Ldap\Server\AccessControl\OperationTargetDn; @@ -25,9 +22,6 @@ use FreeDSx\Ldap\Server\Middleware\Pipeline\MiddlewareInterface; use FreeDSx\Ldap\Server\Middleware\Pipeline\ServerRequestContext; -use function array_filter; -use function array_values; - /** * Refuses a request naming a DN the grammar rejects with invalidDNSyntax (RFC 4511 A.2). * @@ -43,7 +37,7 @@ public function process( ServerRequestContext $context, MiddlewareHandlerInterface $next, ): ResponseStream { - foreach ($this->namedDns($context->message->getRequest()) as $dn) { + foreach (OperationTargetDn::named($context->message->getRequest()) as $dn) { // An unparsed DN would otherwise fall back to a lowercased string and read as a missing entry. if (!Dn::isValid($dn)) { throw new OperationException( @@ -55,24 +49,4 @@ public function process( return $next->handle($context); } - - /** - * The DN fields a request addresses entries by: its target or search base, and a rename's new superior. - * - * @return list - */ - private function namedDns(RequestInterface $request): array - { - $dns = [ - $request instanceof SearchRequest - ? $request->getBaseDn() - : OperationTargetDn::of($request), - ]; - - if ($request instanceof ModifyDnRequest) { - $dns[] = $request->getNewParentDn(); - } - - return array_values(array_filter($dns)); - } } diff --git a/tests/integration/Schema/LdapRelaxControlTest.php b/tests/integration/Schema/LdapRelaxControlTest.php index 49ab9972..f54eb52c 100644 --- a/tests/integration/Schema/LdapRelaxControlTest.php +++ b/tests/integration/Schema/LdapRelaxControlTest.php @@ -134,6 +134,68 @@ public function test_a_relaxed_modify_keeps_the_modify_bookkeeping_it_supplies() ); } + public function test_a_relaxed_modify_removing_the_modify_bookkeeping_restamps_it(): void + { + $this->authenticateAdmin(); + $this->ldapClient()->create(Entry::fromArray( + 'cn=relax-modify-removal,dc=foo,dc=bar', + [ + 'cn' => 'relax-modify-removal', + 'sn' => 'Drift', + 'objectClass' => 'person', + ], + )); + + $this->ldapClient()->send( + Operations::modify( + 'cn=relax-modify-removal,dc=foo,dc=bar', + Change::delete('modifyTimestamp'), + Change::delete('modifiersName'), + ), + Controls::relaxRules(), + ); + + $values = $this->operationalValuesOf('cn=relax-modify-removal,dc=foo,dc=bar'); + + self::assertNotNull($values['modifyTimestamp']); + self::assertNotNull($values['modifiersName']); + } + + public function test_a_relaxed_modify_replacing_then_removing_the_modify_bookkeeping_restamps_it(): void + { + $this->authenticateAdmin(); + $this->ldapClient()->create(Entry::fromArray( + 'cn=relax-modify-late-removal,dc=foo,dc=bar', + [ + 'cn' => 'relax-modify-late-removal', + 'sn' => 'Drift', + 'objectClass' => 'person', + ], + )); + + $this->ldapClient()->send( + Operations::modify( + 'cn=relax-modify-late-removal,dc=foo,dc=bar', + Change::replace( + 'modifyTimestamp', + '20200102000000Z', + ), + Change::delete('modifyTimestamp'), + Change::replace( + 'modifiersName', + 'cn=joe,dc=foo,dc=bar', + ), + Change::delete('modifiersName'), + ), + Controls::relaxRules(), + ); + + $values = $this->operationalValuesOf('cn=relax-modify-late-removal,dc=foo,dc=bar'); + + self::assertNotNull($values['modifyTimestamp']); + self::assertNotNull($values['modifiersName']); + } + public function test_a_relaxed_add_refuses_a_create_timestamp_in_the_future(): void { $this->authenticateAdmin(); diff --git a/tests/integration/Schema/LdapSchemaValidationTest.php b/tests/integration/Schema/LdapSchemaValidationTest.php index 1b4d4c0a..0f3ae7f3 100644 --- a/tests/integration/Schema/LdapSchemaValidationTest.php +++ b/tests/integration/Schema/LdapSchemaValidationTest.php @@ -13,6 +13,7 @@ namespace Tests\Integration\FreeDSx\Ldap\Schema; +use FreeDSx\Ldap\Entry\Change; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\Operation\ResultCode; @@ -128,4 +129,85 @@ public function test_lenient_mode_does_not_allow_equivalent_values(): void ], )); } + + public function test_lenient_mode_refuses_replacing_the_entry_uuid(): void + { + $person = $this->createPerson('drift-uuid'); + + $this->expectException(OperationException::class); + $this->expectExceptionCode(ResultCode::CONSTRAINT_VIOLATION); + + $this->ldapClient()->send(Operations::modify( + $person, + Change::replace( + 'entryUUID', + 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d', + ), + )); + } + + public function test_lenient_mode_refuses_writing_a_derived_attribute(): void + { + $person = $this->createPerson('drift-derived'); + + $this->expectException(OperationException::class); + $this->expectExceptionCode(ResultCode::CONSTRAINT_VIOLATION); + + $this->ldapClient()->send(Operations::modify( + $person, + Change::add( + 'hasSubordinates', + 'TRUE', + ), + )); + } + + public function test_lenient_mode_refuses_renaming_an_entry_to_an_entry_uuid(): void + { + $person = $this->createPerson('drift-uuid-rdn'); + + $this->expectException(OperationException::class); + $this->expectExceptionCode(ResultCode::CONSTRAINT_VIOLATION); + + $this->ldapClient()->rename( + $person, + 'entryUUID=a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d', + false, + ); + } + + public function test_lenient_mode_refuses_adding_an_entry_carrying_a_derived_attribute(): void + { + $this->authenticateAdmin(); + + $this->expectException(OperationException::class); + $this->expectExceptionCode(ResultCode::CONSTRAINT_VIOLATION); + + $this->ldapClient()->create(Entry::fromArray( + 'cn=drift-derived-add,dc=foo,dc=bar', + [ + 'cn' => 'drift-derived-add', + 'sn' => 'Drift', + 'objectClass' => 'person', + 'hasSubordinates' => 'TRUE', + ], + )); + } + + private function createPerson(string $cn): string + { + $this->authenticateAdmin(); + $dn = "cn={$cn},dc=foo,dc=bar"; + + $this->ldapClient()->create(Entry::fromArray( + $dn, + [ + 'cn' => $cn, + 'sn' => 'Drift', + 'objectClass' => 'person', + ], + )); + + return $dn; + } } diff --git a/tests/integration/Security/PasswordPolicyPlainModifyEnforcementTest.php b/tests/integration/Security/PasswordPolicyPlainModifyEnforcementTest.php index 0648c6c4..c3e4c6d0 100644 --- a/tests/integration/Security/PasswordPolicyPlainModifyEnforcementTest.php +++ b/tests/integration/Security/PasswordPolicyPlainModifyEnforcementTest.php @@ -32,6 +32,7 @@ use FreeDSx\Ldap\Server\Backend\Auth\PasswordHashService; use FreeDSx\Ldap\Server\Clock\ClockInterface; use FreeDSx\Ldap\Server\Backend\Storage\Derived\DerivedResolver; +use FreeDSx\Ldap\Server\Backend\Storage\Schema\LinkedAttributes; use FreeDSx\Ldap\Server\Backend\StorageReadBackend; use FreeDSx\Ldap\Server\Backend\Write\PasswordPolicyWriteHandler; use FreeDSx\Ldap\Server\Backend\Write\WriteOperationDispatcher; @@ -429,6 +430,7 @@ private function dispatchHandler( readEntryControlHandler: new ReadEntryControlHandler( new Schema(), $this->createMock(AccessControlInterface::class), + $container->get(LinkedAttributes::class), ), ); } diff --git a/tests/integration/Storage/Concern/BacklinkAttributeTestsTrait.php b/tests/integration/Storage/Concern/BacklinkAttributeTestsTrait.php index bdc1e122..609cea10 100644 --- a/tests/integration/Storage/Concern/BacklinkAttributeTestsTrait.php +++ b/tests/integration/Storage/Concern/BacklinkAttributeTestsTrait.php @@ -66,12 +66,22 @@ public function testASliceOfTheGroupsAnEntryBelongsToIsNamedForWhereItStops(): v $entry = $this->ldapClient()->read($member, ['memberOf;range=1-2']); self::assertSame( - ['memberof;range=1-*'], + ['memberOf;range=1-*'], $this->backlinkNamesOf($entry), ); self::assertCount( 2, - $entry?->get('memberof;range=1-*')?->getValues() ?? [], + $entry?->get('memberOf;range=1-*')?->getValues() ?? [], + ); + } + + public function testABacklinkIsReturnedUnderItsSchemaName(): void + { + [$member] = $this->seedBacklinkPair('backlink-name'); + + self::assertSame( + ['memberOf'], + $this->backlinkNamesOf($this->ldapClient()->read($member, ['MEMBEROF'])), ); } diff --git a/tests/integration/Storage/Concern/LinkedAttributeTestsTrait.php b/tests/integration/Storage/Concern/LinkedAttributeTestsTrait.php index 65ca8281..4fb8c712 100644 --- a/tests/integration/Storage/Concern/LinkedAttributeTestsTrait.php +++ b/tests/integration/Storage/Concern/LinkedAttributeTestsTrait.php @@ -13,12 +13,15 @@ namespace Tests\Integration\FreeDSx\Ldap\Storage\Concern; +use FreeDSx\Ldap\Control\Control; +use FreeDSx\Ldap\Control\ReadEntry\PostReadResponseControl; use FreeDSx\Ldap\Controls; use FreeDSx\Ldap\Entry\Change; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Operations; +use FreeDSx\Ldap\Protocol\LdapMessageResponse; use FreeDSx\Ldap\Search\Filters; /** @@ -338,6 +341,122 @@ public function testARefusedMemberChangeLeavesTheGroupAlone(): void ); } + public function testALinkedAttributeIsReturnedUnderItsSchemaName(): void + { + $person = $this->seedPerson( + 'link-name', + [], + ['SEEALSO' => self::LINKED_ADMIN], + ); + + self::assertSame( + ['seeAlso'], + $this->attributeNamesOf($this->ldapClient()->read($person, ['seeAlso'])), + ); + } + + public function testAValueAddedToALinkedAttributeIsReturnedUnderItsSchemaName(): void + { + $person = $this->seedPerson( + 'link-name-add', + [], + ['seeAlso' => self::LINKED_ADMIN], + ); + + $this->ldapClient()->send(Operations::modify( + $person, + Change::add( + 'SEEALSO', + self::LINKED_USER, + ), + )); + + self::assertSame( + ['seeAlso'], + $this->attributeNamesOf($this->ldapClient()->read($person, ['seeAlso'])), + ); + } + + public function testAChangeLeavingALinkedAttributeAloneKeepsItsSchemaName(): void + { + $person = $this->seedPerson( + 'link-name-kept', + [], + ['seeAlso' => self::LINKED_ADMIN], + ); + + $this->ldapClient()->send(Operations::modify( + $person, + Change::replace( + 'description', + 'Renamed', + ), + )); + + self::assertSame( + ['seeAlso'], + $this->attributeNamesOf($this->ldapClient()->read($person, ['seeAlso'])), + ); + } + + public function testASliceOfALinkedAttributeIsNamedUnderItsSchemaName(): void + { + $person = $this->seedPerson( + 'link-name-slice', + [], + ['seeAlso' => [self::LINKED_ADMIN, self::LINKED_USER]], + ); + + self::assertSame( + ['seeAlso;range=1-*'], + $this->attributeNamesOf($this->ldapClient()->read($person, ['seeAlso;range=1-*'])), + ); + } + + public function testAPostReadOfAnAddNamesALinkedAttributeAsTheSchemaDoes(): void + { + $this->authenticateAdmin(); + + $response = $this->ldapClient()->sendAndReceive( + Operations::add(Entry::fromArray( + 'cn=link-name-post-add,dc=foo,dc=bar', + [ + 'cn' => 'link-name-post-add', + 'sn' => 'link-name-post-add', + 'objectClass' => 'person', + 'SEEALSO' => self::LINKED_ADMIN, + ], + )), + Controls::postRead('seeAlso'), + ); + + self::assertSame( + ['seeAlso'], + $this->attributeNamesOf($this->postReadOf($response)), + ); + } + + public function testAPostReadOfAModifyNamesALinkedAttributeAsTheSchemaDoes(): void + { + $person = $this->seedPerson('link-name-post-modify'); + + $response = $this->ldapClient()->sendAndReceive( + Operations::modify( + $person, + Change::replace( + 'SEEALSO', + self::LINKED_ADMIN, + ), + ), + Controls::postRead('seeAlso'), + ); + + self::assertSame( + ['seeAlso'], + $this->attributeNamesOf($this->postReadOf($response)), + ); + } + public function testASliceOfAMembershipIsReturnedUnderTheRangeItHolds(): void { // Four members, so the slice asked for stops short of the end and is named for where it stops. @@ -481,7 +600,7 @@ private function seedGroup( /** * @param list $auxiliaries - * @param array $attributes + * @param array> $attributes */ private function seedPerson( string $cn, @@ -517,6 +636,15 @@ private function membersOf(string $dn): array ); } + private function postReadOf(LdapMessageResponse $response): ?Entry + { + $control = $response->controls()->get(Control::OID_POST_READ); + + return $control instanceof PostReadResponseControl + ? $control->getEntry() + : null; + } + private function expectOperationCode(int $code): void { $this->expectException(OperationException::class); diff --git a/tests/integration/Storage/Concern/Write/DnSpellingTestsTrait.php b/tests/integration/Storage/Concern/Write/DnSpellingTestsTrait.php index 80c8323a..ff12d641 100644 --- a/tests/integration/Storage/Concern/Write/DnSpellingTestsTrait.php +++ b/tests/integration/Storage/Concern/Write/DnSpellingTestsTrait.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\RequestInterface; use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Operations; use FreeDSx\Ldap\Search\Filters; @@ -484,4 +485,81 @@ public function testBothSpellingsOfAnEscapedCommaNameOneEntry(): void $entries->first()?->get(new Attribute('cn'), true)?->getValues(), ); } + + public function testAnAddUnderAMissingParentMatchesTheAncestorAsTheRequestSpelledIt(): void + { + self::assertSame( + 'OU=People,DC=Foo,DC=Bar', + $this->matchedDnOf(Operations::add(Entry::fromArray( + 'cn=orphan,OU=Missing,OU=People,DC=Foo,DC=Bar', + [ + 'objectClass' => 'person', + 'cn' => 'orphan', + 'sn' => 'Orphan', + ], + ))), + ); + } + + public function testAModifyOfAMissingEntryMatchesTheParentAsTheRequestSpelledIt(): void + { + self::assertSame( + 'OU=People,DC=Foo,DC=Bar', + $this->matchedDnOf(Operations::modify( + 'cn=missing,OU=People,DC=Foo,DC=Bar', + Change::replace( + 'description', + 'changed', + ), + )), + ); + } + + public function testADeleteOfAMissingEntryMatchesTheParentAsTheRequestSpelledIt(): void + { + self::assertSame( + 'OU=People,DC=Foo,DC=Bar', + $this->matchedDnOf(Operations::delete('cn=missing,OU=People,DC=Foo,DC=Bar')), + ); + } + + public function testARenameOfAMissingEntryMatchesTheParentAsTheRequestSpelledIt(): void + { + self::assertSame( + 'OU=People,DC=Foo,DC=Bar', + $this->matchedDnOf(Operations::rename( + 'cn=missing,OU=People,DC=Foo,DC=Bar', + 'cn=renamed', + )), + ); + } + + public function testAMoveUnderAMissingSuperiorMatchesItsAncestorAsTheRequestSpelledIt(): void + { + self::assertSame( + 'OU=People,DC=Foo,DC=Bar', + $this->matchedDnOf(Operations::move( + 'cn=user,dc=foo,dc=bar', + 'OU=Missing,OU=People,DC=Foo,DC=Bar', + )), + ); + } + + private function matchedDnOf(RequestInterface $request): ?string + { + $this->authenticateAdmin(); + + try { + $this->ldapClient()->send($request); + } catch (OperationException $e) { + self::assertSame( + ResultCode::NO_SUCH_OBJECT, + $e->getCode(), + ); + + return $e->getMatchedDn()?->toString(); + } + + self::fail('The request should have been refused as naming no such object.'); + } } diff --git a/tests/integration/Storage/Concern/Write/StorageBoundsTestsTrait.php b/tests/integration/Storage/Concern/Write/StorageBoundsTestsTrait.php index 6ff350ec..101aaa87 100644 --- a/tests/integration/Storage/Concern/Write/StorageBoundsTestsTrait.php +++ b/tests/integration/Storage/Concern/Write/StorageBoundsTestsTrait.php @@ -269,6 +269,30 @@ public function testAnAttributeDescriptionNamingNoTypeIsRefusedWithValidationOff ))); } + public function testANonAsciiAttributeTypeIsRefusedAsUndefinedWithValidationOff(): void + { + $this->stopServer(); + $this->createServerProcess( + 'tcp', + [ + ...static::storageExtraArgs(), + '--validation-mode=off', + ], + ); + $this->authenticateAdmin(); + + $this->expectException(OperationException::class); + $this->expectExceptionCode(ResultCode::UNDEFINED_ATTRIBUTE_TYPE); + + $this->ldapClient()->send(Operations::add(new Entry( + 'cn=nonascii,dc=foo,dc=bar', + new Attribute('objectClass', 'top', 'inetOrgPerson'), + new Attribute('cn', 'nonascii'), + new Attribute('sn', 'Non Ascii'), + new Attribute('café', 'value'), + ))); + } + public function testAModifyAddingADescriptionNamingNoTypeIsRefusedWithValidationOff(): void { $this->stopServer(); diff --git a/tests/integration/Storage/Pdo/LinkSpanReaderTest.php b/tests/integration/Storage/Pdo/LinkSpanReaderTest.php index dcad823b..b13836cf 100644 --- a/tests/integration/Storage/Pdo/LinkSpanReaderTest.php +++ b/tests/integration/Storage/Pdo/LinkSpanReaderTest.php @@ -17,6 +17,8 @@ use FreeDSx\Ldap\Entry\Attribute; use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Schema\Definition\AttributeType; +use FreeDSx\Ldap\Schema\SchemaResource; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect\PdoDialectInterface; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect\SqliteDialect; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\Connection\PdoConnection; @@ -26,7 +28,7 @@ use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\PdoSchema; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\Writer\EntryWriter; use FreeDSx\Ldap\Server\Backend\Storage\Link\LinkDirection; -use FreeDSx\Ldap\Server\Backend\Storage\Schema\Backlinks; +use FreeDSx\Ldap\Server\Backend\Storage\Schema\LinkedAttributes; use PDO; use PHPUnit\Framework\TestCase; use Tests\Support\FreeDSx\Ldap\Server\Configuration\TestServerOptions; @@ -40,7 +42,13 @@ final class LinkSpanReaderTest extends TestCase private const MEMBER = 'cn=alice,dc=example,dc=com'; - private LinkSpanReader $subject; + private const STAFF = 'cn=staff,dc=example,dc=com'; + + private const SECOND_MEMBER = 'cn=bob,dc=example,dc=com'; + + private LinkSpanReader $backward; + + private LinkSpanReader $forward; private EntryWriter $storage; @@ -57,15 +65,24 @@ protected function setUp(): void ); $this->storage = $container->get(EntryWriter::class); $this->connection = $container->get(PdoConnection::class); - $reversing = [ - 'memberof' => 'member', - 'ismemberof' => 'member', - ]; - $this->subject = new LinkSpanReader( - $container->get(PdoDialectInterface::class), + $dialect = $container->get(PdoDialectInterface::class); + $linked = new LinkedAttributes(SchemaResource::Core->load()->addAttributeType(new AttributeType( + '1.400', + ['isMemberOf'], + noUserModification: true, + extensions: [AttributeType::EXTENSION_LINKED_BY => ['member']], + ))); + $this->backward = new LinkSpanReader( + $dialect, $this->connection, LinkDirection::Backward, - new Backlinks($reversing, $reversing), + $linked, + ); + $this->forward = new LinkSpanReader( + $dialect, + $this->connection, + LinkDirection::Forward, + $linked, ); $this->seed(); @@ -75,10 +92,10 @@ public function test_it_reads_values_back_under_every_backlink_reversing_the_att { self::assertSame( [ - 'memberof' => [self::GROUP], - 'ismemberof' => [self::GROUP], + 'memberOf' => [self::GROUP], + 'isMemberOf' => [self::GROUP], ], - $this->subject->forSpan(...$this->spanOf(self::MEMBER))[$this->entryIdOf(self::MEMBER)] ?? [], + $this->backward->forSpan(...$this->spanOf(self::MEMBER))[$this->entryIdOf(self::MEMBER)] ?? [], ); } @@ -86,10 +103,71 @@ public function test_an_entry_nothing_names_reads_back_nothing(): void { self::assertSame( [], - $this->subject->forSpan(...$this->spanOf(self::GROUP))[$this->entryIdOf(self::GROUP)] ?? [], + $this->backward->forSpan(...$this->spanOf(self::GROUP))[$this->entryIdOf(self::GROUP)] ?? [], + ); + } + + public function test_reading_forwards_names_each_attribute_as_the_schema_does(): void + { + self::assertSame( + [ + 'member' => [self::MEMBER], + 'seeAlso' => [self::MEMBER], + ], + $this->forward->forSpan(...$this->spanOf(self::GROUP))[$this->entryIdOf(self::GROUP)] ?? [], + ); + } + + public function test_a_linked_attribute_over_the_cap_is_ranged_under_its_schema_name(): void + { + $this->seedStaff(); + + self::assertSame( + [ + 'member;range=0-0' => [self::MEMBER], + 'seeAlso;range=0-0' => [self::MEMBER], + ], + $this->cappedAtOne( + $this->forward, + self::STAFF, + ), ); } + public function test_a_backlink_over_the_cap_is_ranged_under_its_schema_name(): void + { + $this->seedStaff(); + + self::assertSame( + [ + 'isMemberOf;range=0-0' => [self::GROUP], + 'memberOf;range=0-0' => [self::GROUP], + ], + $this->cappedAtOne( + $this->backward, + self::MEMBER, + ), + ); + } + + /** + * @return array> + */ + private function cappedAtOne( + LinkSpanReader $reader, + string $dn, + ): array { + $id = $this->entryIdOf($dn); + $links = $reader->forSpan( + $id, + $id, + 1, + )[$id] ?? []; + ksort($links); + + return $links; + } + /** * @return array{int, int, int} */ @@ -131,6 +209,21 @@ private function seed(): void new Dn(self::GROUP), new Attribute('cn', 'admins'), new Attribute('member', self::MEMBER), + new Attribute('seealso', self::MEMBER), + ))); + } + + private function seedStaff(): void + { + $this->storage->store(EntryFixture::withUuid(new Entry( + new Dn(self::SECOND_MEMBER), + new Attribute('cn', 'bob'), + ))); + $this->storage->store(EntryFixture::withUuid(new Entry( + new Dn(self::STAFF), + new Attribute('cn', 'staff'), + new Attribute('member', self::MEMBER, self::SECOND_MEMBER), + new Attribute('seealso', self::MEMBER, self::SECOND_MEMBER), ))); } } diff --git a/tests/unit/Protocol/ServerProtocolHandler/ReadEntryControlHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ReadEntryControlHandlerTest.php index a0a87539..773a78b8 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ReadEntryControlHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ReadEntryControlHandlerTest.php @@ -12,6 +12,7 @@ use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ReadEntryControlHandler; use FreeDSx\Ldap\Schema\Schema; +use FreeDSx\Ldap\Schema\SchemaResource; use FreeDSx\Ldap\Server\AccessControl\AclRules; use FreeDSx\Ldap\Operation\OperationType; use FreeDSx\Ldap\Server\AccessControl\Rule\AttributeRule; @@ -19,6 +20,7 @@ use FreeDSx\Ldap\Server\AccessControl\RuleBasedAccessControl; use FreeDSx\Ldap\Server\AccessControl\Subject\Subject; use FreeDSx\Ldap\Server\AccessControl\Target\Target; +use FreeDSx\Ldap\Server\Backend\Storage\Schema\LinkedAttributes; use FreeDSx\Ldap\Server\Token\BindToken; use FreeDSx\Ldap\Server\Token\TokenInterface; use PHPUnit\Framework\TestCase; @@ -31,13 +33,17 @@ final class ReadEntryControlHandlerTest extends TestCase private TokenInterface $token; + private LinkedAttributes $linked; + protected function setUp(): void { $this->entry = Entry::fromArray('cn=foo,dc=ex,dc=com', ['cn' => ['foo']]); $this->token = BindToken::fromDn('cn=foo,dc=ex,dc=com'); + $this->linked = new LinkedAttributes(SchemaResource::Core->load()); $this->subject = new ReadEntryControlHandler( new Schema(), new RuleBasedAccessControl(AclRules::fromEmpty(operations: self::searchAllowed())), + $this->linked, ); } @@ -98,6 +104,27 @@ public function test_post_read_projects_only_the_requested_attributes(): void ); } + public function test_post_read_names_a_linked_attribute_as_the_schema_does(): void + { + $control = $this->subject->postRead( + Entry::fromArray('cn=foo,dc=ex,dc=com', [ + 'cn' => ['foo'], + 'MEMBER' => ['cn=bar,dc=ex,dc=com'], + ]), + new ControlBag(new PostReadControl('member')), + $this->token, + ); + + self::assertInstanceOf(PostReadResponseControl::class, $control); + self::assertSame( + ['member'], + array_map( + static fn($attr): string => $attr->getName(), + $control->getEntry()->getAttributes(), + ), + ); + } + public function test_the_control_is_isolated_from_later_mutation_of_the_entry(): void { $control = $this->subject->preRead( @@ -157,6 +184,7 @@ public function test_no_control_is_returned_when_the_token_may_not_read_the_targ $subject = new ReadEntryControlHandler( new Schema(), new RuleBasedAccessControl(AclRules::fromEmpty()), + $this->linked, ); self::assertNull($subject->preRead( @@ -194,6 +222,7 @@ private function denyingUserPassword(): ReadEntryControlHandler )->forRead(), ], )), + $this->linked, ); } } diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerDispatchHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerDispatchHandlerTest.php index b9f70931..b67f8dbc 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerDispatchHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerDispatchHandlerTest.php @@ -85,6 +85,7 @@ protected function setUp(): void readEntryControlHandler: new ReadEntryControlHandler( new Schema(), $this->mockAccessControl, + new LinkedAttributes(new Schema()), ), ); } diff --git a/tests/unit/Schema/TextTest.php b/tests/unit/Schema/TextTest.php index 748ba3c9..94113fd6 100644 --- a/tests/unit/Schema/TextTest.php +++ b/tests/unit/Schema/TextTest.php @@ -30,6 +30,17 @@ public function test_is_ascii( ); } + #[DataProvider('printableAsciiProvider')] + public function test_is_printable_ascii( + string $value, + bool $expected, + ): void { + self::assertSame( + $expected, + Text::isPrintableAscii($value), + ); + } + #[DataProvider('utf8Provider')] public function test_is_utf8( string $value, @@ -67,6 +78,22 @@ public static function asciiProvider(): array ]; } + /** + * @return array + */ + public static function printableAsciiProvider(): array + { + return [ + 'plain ascii' => ['user@example.com', true], + 'empty' => ['', true], + 'space' => ['a b', true], + 'tab' => ["a\tb", false], + 'trailing newline' => ["cn\n", false], + 'delete' => ["a\x7Fb", false], + 'accented letter' => ['café', false], + ]; + } + /** * @return array */ diff --git a/tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageTest.php b/tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageTest.php index 5f2f0429..495b54d6 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/InMemoryStorageTest.php @@ -238,6 +238,71 @@ public function test_a_replacing_entry_does_not_inherit_the_members_of_the_entry self::assertNull($subject->find(new Dn('cn=admins,dc=ex,dc=com'))?->get('member')); } + public function test_an_entry_written_whole_holds_its_linked_attributes_under_their_schema_names(): void + { + $subject = new InMemoryStorage( + linkedAttributes: new LinkedAttributes(SchemaResource::Core->load()), + ); + + $subject->store(EntryFixture::withUuid(Entry::fromArray( + 'cn=alice,dc=ex,dc=com', + ['cn' => ['alice'], 'SEEALSO' => ['cn=bob,dc=ex,dc=com']], + ))); + + self::assertSame( + 'seeAlso', + $subject->find(new Dn('cn=alice,dc=ex,dc=com'))?->get('seeAlso')?->getName(), + ); + } + + public function test_a_delta_holds_the_attribute_it_changes_under_its_schema_name(): void + { + $subject = new InMemoryStorage( + linkedAttributes: new LinkedAttributes(SchemaResource::Core->load()), + ); + $subject->store(EntryFixture::withUuid(Entry::fromArray( + 'cn=alice,dc=ex,dc=com', + ['cn' => ['alice'], 'seeAlso' => ['cn=bob,dc=ex,dc=com']], + ))); + + $subject->store( + EntryFixture::withUuid(Entry::fromArray( + 'cn=alice,dc=ex,dc=com', + ['cn' => ['alice']], + )), + links: new LinkDelta(added: ['seealso' => ['cn=carol,dc=ex,dc=com']]), + ); + + self::assertSame( + 'seeAlso', + $subject->find(new Dn('cn=alice,dc=ex,dc=com'))?->get('seeAlso')?->getName(), + ); + } + + public function test_an_untouched_write_holds_the_kept_values_under_their_schema_name(): void + { + $subject = new InMemoryStorage( + linkedAttributes: new LinkedAttributes(SchemaResource::Core->load()), + ); + $subject->store(EntryFixture::withUuid(Entry::fromArray( + 'cn=alice,dc=ex,dc=com', + ['cn' => ['alice'], 'seeAlso' => ['cn=bob,dc=ex,dc=com']], + ))); + + $subject->store( + EntryFixture::withUuid(Entry::fromArray( + 'cn=alice,dc=ex,dc=com', + ['cn' => ['alice'], 'description' => ['Renamed']], + )), + links: LinkDelta::untouched(), + ); + + self::assertSame( + 'seeAlso', + $subject->find(new Dn('cn=alice,dc=ex,dc=com'))?->get('seeAlso')?->getName(), + ); + } + public function test_find_returns_null_for_unknown_norm_dn(): void { self::assertNull($this->subject->find(new Dn('cn=nobody,dc=example,dc=com'))); diff --git a/tests/unit/Server/Backend/Storage/Import/LdapImporterTest.php b/tests/unit/Server/Backend/Storage/Import/LdapImporterTest.php index 64c114cb..3c22379e 100644 --- a/tests/unit/Server/Backend/Storage/Import/LdapImporterTest.php +++ b/tests/unit/Server/Backend/Storage/Import/LdapImporterTest.php @@ -324,6 +324,27 @@ public function test_importEntries_ignoreValidation_still_rejects_an_invalid_att ); } + public function test_importEntries_ignoreValidation_still_refuses_a_derived_attribute(): void + { + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::CONSTRAINT_VIOLATION); + + $this->importerFor( + $this->storage, + TestServerOptions::validatedCore(SchemaValidationMode::Strict), + )->importEntries( + entries: [ + new Entry( + new Dn('dc=example,dc=com'), + new Attribute('dc', 'example'), + new Attribute('objectClass', 'top', 'domain'), + new Attribute('hasSubordinates', 'TRUE'), + ), + ], + ignoreValidation: true, + ); + } + public function test_importEntries_stores_an_entry_spelled_with_aliases_under_the_primary_names(): void { $this->importer()->importEntries([ diff --git a/tests/unit/Server/Backend/Storage/Schema/LinkedAttributesTest.php b/tests/unit/Server/Backend/Storage/Schema/LinkedAttributesTest.php index 1db85dd3..8483d59b 100644 --- a/tests/unit/Server/Backend/Storage/Schema/LinkedAttributesTest.php +++ b/tests/unit/Server/Backend/Storage/Schema/LinkedAttributesTest.php @@ -14,6 +14,7 @@ namespace Tests\Unit\FreeDSx\Ldap\Server\Backend\Storage\Schema; use FreeDSx\Ldap\Entry\Attribute; +use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\RuntimeException; use FreeDSx\Ldap\Schema\Definition\AttributeType; use FreeDSx\Ldap\Schema\Definition\ObjectClass; @@ -148,7 +149,7 @@ public function test_a_linked_attribute_is_not_itself_a_backlink(): void public function test_it_reports_the_backlink_reversing_each_linked_name(): void { self::assertSame( - ['memberof'], + ['memberOf'], $this->subject->backlinks()->reversing('member'), ); self::assertSame( @@ -157,6 +158,68 @@ public function test_it_reports_the_backlink_reversing_each_linked_name(): void ); } + public function test_a_request_matches_the_backlink_names_lowercased(): void + { + self::assertSame( + ['memberof'], + $this->subject->backlinks()->names(), + ); + self::assertSame( + ['memberOf'], + $this->subject->backlinks()->only(['memberof'])->reversing('member'), + ); + } + + public function test_a_linked_attribute_or_backlink_is_named_by_its_primary_name(): void + { + self::assertSame( + 'seeAlso', + $this->subject->primaryName('SEEALSO'), + ); + self::assertSame( + 'memberOf', + $this->subject->primaryName('memberof'), + ); + } + + public function test_an_entry_naming_its_links_as_the_schema_does_is_returned_as_is(): void + { + $entry = Entry::fromArray( + 'cn=foo,dc=example,dc=com', + [ + 'cn' => 'foo', + 'seeAlso' => 'cn=bar,dc=example,dc=com', + ], + ); + + self::assertSame( + $entry, + $this->subject->withSchemaNames($entry), + ); + } + + public function test_only_a_linked_attribute_spelled_otherwise_is_renamed_and_on_a_copy(): void + { + $entry = Entry::fromArray( + 'cn=foo,dc=example,dc=com', + [ + 'CN' => 'foo', + 'SEEALSO' => 'cn=bar,dc=example,dc=com', + ], + ); + + self::assertSame( + [ + ['CN', 'seeAlso'], + ['CN', 'SEEALSO'], + ], + [ + self::namesOf($this->subject->withSchemaNames($entry)), + self::namesOf($entry), + ], + ); + } + public function test_both_ends_of_a_link_are_held_apart_from_the_entry(): void { self::assertTrue($this->subject->heldApart(new Attribute('member'))); @@ -209,7 +272,7 @@ public function test_more_than_one_backlink_may_reverse_the_same_attribute(): vo $subject->assertBacklinksAreDerivable(); self::assertEqualsCanonicalizing( - ['memberof', 'ismemberof'], + ['memberOf', 'isMemberOf'], $subject->backlinks()->reversing('member'), ); self::assertSame( @@ -230,7 +293,7 @@ public function test_a_backlink_is_read_under_one_of_its_names_only(): void ); self::assertSame( - ['ownedby'], + ['ownedBy'], $subject->backlinks()->reversing('owner'), ); // Either name still resolves, so a filter naming the alias is answered from the same rows. @@ -300,4 +363,15 @@ private function links(string $description): bool { return $this->subject->links(new Attribute($description)); } + + /** + * @return list + */ + private static function namesOf(Entry $entry): array + { + return array_map( + static fn(Attribute $attribute): string => $attribute->getName(), + array_values($entry->getAttributes()), + ); + } } diff --git a/tests/unit/Server/Backend/Write/Operation/AddEntryHandlerTest.php b/tests/unit/Server/Backend/Write/Operation/AddEntryHandlerTest.php index 5b3a10c9..2180e698 100644 --- a/tests/unit/Server/Backend/Write/Operation/AddEntryHandlerTest.php +++ b/tests/unit/Server/Backend/Write/Operation/AddEntryHandlerTest.php @@ -39,6 +39,7 @@ use FreeDSx\Ldap\Server\Backend\Write\WriteContext; use FreeDSx\Ldap\Server\Token\AnonToken; use FreeDSx\Ldap\ServerOptions; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Tests\Support\FreeDSx\Ldap\Backend\Write\WriteHandlerTestTrait; use Tests\Support\FreeDSx\Ldap\Server\Configuration\TestServerOptions; @@ -405,6 +406,103 @@ public function test_a_strict_validator_accepts_a_valid_entry(): void self::assertNotNull($this->find('cn=Alice,dc=example,dc=com')); } + #[DataProvider('validationModeProvider')] + public function test_an_add_supplying_a_derived_attribute_is_refused_in_every_validation_mode( + SchemaValidationMode $mode, + ): void { + $this->validatedGraph($mode); + + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::CONSTRAINT_VIOLATION); + + $this->adds()->handle( + new AddCommand(Entry::fromArray( + 'cn=Alice,dc=example,dc=com', + [ + 'objectClass' => ['person'], + 'cn' => ['Alice'], + 'sn' => ['Smith'], + 'hasSubordinates' => ['TRUE'], + ], + )), + $this->context(), + ); + } + + /** + * @return iterable + */ + public static function validationModeProvider(): iterable + { + foreach (SchemaValidationMode::cases() as $mode) { + yield $mode->name => [$mode]; + } + } + + #[DataProvider('undefinableTypeProvider')] + public function test_an_add_naming_a_type_no_schema_can_define_is_refused_in_every_validation_mode( + SchemaValidationMode $mode, + string $type, + ): void { + $this->validatedGraph($mode); + + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::UNDEFINED_ATTRIBUTE_TYPE); + + $this->adds()->handle( + new AddCommand(Entry::fromArray( + 'cn=Alice,dc=example,dc=com', + [ + 'objectClass' => ['person'], + 'cn' => ['Alice'], + 'sn' => ['Smith'], + $type => ['value'], + ], + )), + $this->context(), + ); + } + + /** + * @return iterable + */ + public static function undefinableTypeProvider(): iterable + { + foreach (SchemaValidationMode::cases() as $mode) { + yield "non-ASCII under {$mode->name}" => [ + $mode, + 'café', + ]; + yield "control character under {$mode->name}" => [ + $mode, + "a\tb", + ]; + } + } + + #[DataProvider('validationModeProvider')] + public function test_an_add_naming_a_type_beyond_the_storage_bound_is_refused_in_every_validation_mode( + SchemaValidationMode $mode, + ): void { + $this->validatedGraph($mode); + + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::ADMIN_LIMIT_EXCEEDED); + + $this->adds()->handle( + new AddCommand(Entry::fromArray( + 'cn=Alice,dc=example,dc=com', + [ + 'objectClass' => ['person'], + 'cn' => ['Alice'], + 'sn' => ['Smith'], + 'a' . str_repeat('b', Attribute::MAX_TYPE_LENGTH) => ['value'], + ], + )), + $this->context(), + ); + } + public function test_a_lenient_validator_records_the_violation_and_writes_anyway(): void { $this->validatedGraph(SchemaValidationMode::Lenient); diff --git a/tests/unit/Server/Backend/Write/Operation/MoveEntryHandlerTest.php b/tests/unit/Server/Backend/Write/Operation/MoveEntryHandlerTest.php index a64640bd..79c56a3b 100644 --- a/tests/unit/Server/Backend/Write/Operation/MoveEntryHandlerTest.php +++ b/tests/unit/Server/Backend/Write/Operation/MoveEntryHandlerTest.php @@ -22,6 +22,7 @@ use FreeDSx\Ldap\Entry\Rdn; use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Schema\SchemaValidationMode; use FreeDSx\Ldap\Search\Filters; use FreeDSx\Ldap\Server\Backend\Storage\Contract\TransactionalWriteInterface; use FreeDSx\Ldap\Server\Backend\Storage\Exception\StorageIoException; @@ -33,6 +34,7 @@ use FreeDSx\Ldap\Server\Backend\Write\Command\AddCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\MoveCommand; use FreeDSx\Ldap\ServerOptions; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Tests\Support\FreeDSx\Ldap\Backend\Write\WriteHandlerTestTrait; use Tests\Support\FreeDSx\Ldap\Server\Configuration\TestServerOptions; @@ -96,6 +98,35 @@ public function test_it_refuses_an_entry_that_does_not_exist(): void $this->rename('cn=Nobody,dc=example,dc=com', 'cn=Ghost'); } + #[DataProvider('validationModeProvider')] + public function test_a_rename_naming_the_entry_by_an_entry_uuid_is_refused_in_every_validation_mode( + SchemaValidationMode $mode, + ): void { + $this->writeGraph( + EntryFixture::inMemoryStorage(...$this->fixture()), + TestServerOptions::validatedCore($mode), + ); + + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::CONSTRAINT_VIOLATION); + + $this->rename( + self::ALICE, + 'entryUUID=a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d', + deleteOldRdn: false, + ); + } + + /** + * @return iterable + */ + public static function validationModeProvider(): iterable + { + foreach (SchemaValidationMode::cases() as $mode) { + yield $mode->name => [$mode]; + } + } + public function test_a_failing_assertion_refuses_the_rename_and_leaves_the_entry(): void { try { diff --git a/tests/unit/Server/Backend/Write/Operation/UpdateEntryHandlerTest.php b/tests/unit/Server/Backend/Write/Operation/UpdateEntryHandlerTest.php index f1e904d8..59e21006 100644 --- a/tests/unit/Server/Backend/Write/Operation/UpdateEntryHandlerTest.php +++ b/tests/unit/Server/Backend/Write/Operation/UpdateEntryHandlerTest.php @@ -40,6 +40,7 @@ use FreeDSx\Ldap\Server\Backend\Write\WriteContext; use FreeDSx\Ldap\Server\Token\AnonToken; use FreeDSx\Ldap\ServerOptions; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Tests\Support\FreeDSx\Ldap\Backend\Write\WriteHandlerTestTrait; use Tests\Support\FreeDSx\Ldap\Server\Configuration\TestServerOptions; @@ -370,6 +371,49 @@ public function test_a_lenient_validator_records_the_violation_and_writes_anyway ); } + #[DataProvider('serverOwnedChangeProvider')] + public function test_a_change_to_a_server_owned_attribute_is_refused_in_every_validation_mode( + SchemaValidationMode $mode, + Change $change, + ): void { + $this->validatedGraph($mode); + + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::CONSTRAINT_VIOLATION); + + $this->modify($change); + } + + /** + * @return iterable + */ + public static function serverOwnedChangeProvider(): iterable + { + foreach (SchemaValidationMode::cases() as $mode) { + yield "replace entryUUID under {$mode->name}" => [ + $mode, + Change::replace( + 'entryUUID', + 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d', + ), + ]; + yield "add hasSubordinates under {$mode->name}" => [ + $mode, + Change::add( + 'hasSubordinates', + 'TRUE', + ), + ]; + yield "add memberOf under {$mode->name}" => [ + $mode, + Change::add( + 'memberOf', + self::ADMINS, + ), + ]; + } + } + public function test_a_system_write_bypasses_the_no_user_modification_check(): void { $this->validatedGraph(SchemaValidationMode::Strict); @@ -388,6 +432,22 @@ public function test_a_system_write_bypasses_the_no_user_modification_check(): v ); } + public function test_a_system_write_still_refuses_a_server_owned_attribute(): void + { + $this->validatedGraph(SchemaValidationMode::Strict); + + self::expectException(OperationException::class); + self::expectExceptionCode(ResultCode::CONSTRAINT_VIOLATION); + + $this->updates()->handle( + new UpdateCommand( + new Dn(self::ALICE), + [Change::add(new Attribute('hasSubordinates', 'TRUE'))], + ), + $this->systemContext(), + ); + } + public function test_a_system_write_still_enforces_a_single_valued_attribute(): void { $this->validatedGraph(SchemaValidationMode::Strict); diff --git a/tests/unit/Server/Backend/Write/OperationalAttributeGeneratorTest.php b/tests/unit/Server/Backend/Write/OperationalAttributeGeneratorTest.php index dbb08ae2..f1688aec 100644 --- a/tests/unit/Server/Backend/Write/OperationalAttributeGeneratorTest.php +++ b/tests/unit/Server/Backend/Write/OperationalAttributeGeneratorTest.php @@ -384,6 +384,50 @@ public function test_apply_for_modify_keeps_the_modify_bookkeeping_the_changes_s ); } + public function test_apply_for_modify_restamps_the_modify_bookkeeping_the_changes_remove(): void + { + $this->subject->applyForModify( + $this->entry, + $this->boundContext('cn=admin,dc=example,dc=com'), + [ + Change::delete('modifyTimestamp'), + Change::replace('modifiersName'), + ], + ); + + self::assertMatchesRegularExpression( + '/^\d{14}Z$/', + $this->entry->get('modifyTimestamp')?->firstValue() ?? '', + ); + self::assertSame( + 'cn=admin,dc=example,dc=com', + $this->entry->get('modifiersName')?->firstValue(), + ); + } + + public function test_apply_for_modify_restamps_the_modify_bookkeeping_a_later_change_removes(): void + { + $this->subject->applyForModify( + $this->entry, + $this->boundContext('cn=admin,dc=example,dc=com'), + [ + Change::replace('modifyTimestamp', '20200102000000Z'), + Change::delete('modifyTimestamp'), + Change::replace('modifiersName', 'cn=Joe,dc=example,dc=com'), + Change::delete('modifiersName'), + ], + ); + + self::assertMatchesRegularExpression( + '/^\d{14}Z$/', + $this->entry->get('modifyTimestamp')?->firstValue() ?? '', + ); + self::assertSame( + 'cn=admin,dc=example,dc=com', + $this->entry->get('modifiersName')?->firstValue(), + ); + } + public function test_apply_for_modify_refuses_a_supplied_create_timestamp_in_the_future(): void { $this->entry->set('createTimestamp', '20990101000000Z'); diff --git a/tests/unit/Server/Middleware/ResponseWriterMiddlewareTest.php b/tests/unit/Server/Middleware/ResponseWriterMiddlewareTest.php index 8744c265..4fb712df 100644 --- a/tests/unit/Server/Middleware/ResponseWriterMiddlewareTest.php +++ b/tests/unit/Server/Middleware/ResponseWriterMiddlewareTest.php @@ -21,9 +21,11 @@ use FreeDSx\Ldap\Exception\ProtocolException; use RuntimeException; use FreeDSx\Ldap\Operation\Request\DeleteRequest; +use FreeDSx\Ldap\Operation\Request\ModifyDnRequest; use FreeDSx\Ldap\Operation\Request\RequestInterface; use FreeDSx\Ldap\Operation\Request\SearchRequest; use FreeDSx\Ldap\Operation\Response\DeleteResponse; +use FreeDSx\Ldap\Operation\Response\ModifyDnResponse; use FreeDSx\Ldap\Operation\Response\SearchResultDone; use FreeDSx\Ldap\Operation\Response\SearchResultEntry; use FreeDSx\Ldap\Operation\ResultCode; @@ -252,6 +254,62 @@ public function test_it_drops_a_matched_dn_when_the_backend_has_no_ancestor(): v ); } + public function test_a_matched_dn_is_spelled_as_the_request_named_it(): void + { + $this->givenTheMatchedEntryIsVisible(); + + $this->subject->process( + $this->contextFor(new DeleteRequest('cn=missing,OU=People,DC=Bar')), + new ThrowingMiddlewareHandler($this->noSuchObject(new Dn('ou=people,dc=bar'))), + ); + + $response = $this->firstSent()?->getResponse(); + self::assertInstanceOf(DeleteResponse::class, $response); + self::assertSame( + 'OU=People,DC=Bar', + $response->getDn()->toString(), + ); + } + + public function test_a_matched_dn_above_a_new_superior_is_spelled_as_the_request_named_it(): void + { + $this->givenTheMatchedEntryIsVisible(); + + $this->subject->process( + $this->contextFor(new ModifyDnRequest( + 'cn=moving,dc=bar', + 'cn=moving', + true, + 'OU=Missing,OU=Moved,DC=Bar', + )), + new ThrowingMiddlewareHandler($this->noSuchObject(new Dn('ou=moved,dc=bar'))), + ); + + $response = $this->firstSent()?->getResponse(); + self::assertInstanceOf(ModifyDnResponse::class, $response); + self::assertSame( + 'OU=Moved,DC=Bar', + $response->getDn()->toString(), + ); + } + + public function test_a_matched_dn_the_request_did_not_name_is_kept_as_given(): void + { + $this->givenTheMatchedEntryIsVisible(); + + $this->subject->process( + $this->contextFor(new DeleteRequest('cn=missing,dc=bar')), + new ThrowingMiddlewareHandler($this->noSuchObject(new Dn('dc=elsewhere'))), + ); + + $response = $this->firstSent()?->getResponse(); + self::assertInstanceOf(DeleteResponse::class, $response); + self::assertSame( + 'dc=elsewhere', + $response->getDn()->toString(), + ); + } + public function test_it_answers_a_storage_failure_without_naming_the_internals(): void { $this->subject->process( @@ -321,6 +379,16 @@ public function test_it_leaves_a_session_ending_failure_to_the_connection(): voi ); } + private function givenTheMatchedEntryIsVisible(): void + { + $this->mockBackend + ->method('get') + ->willReturn(Entry::create('dc=bar')); + $this->mockAccessControl + ->method('isEntryVisible') + ->willReturn(true); + } + private function noSuchObject(Dn $matchedDn): OperationException { return new OperationException(