diff --git a/CHANGELOG.md b/CHANGELOG.md index d5989601c..133926119 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 8.0.3 + +### Added + +- `Database::authorizeCreate()` and `Database::authorizeUpdate()` take an optional resolver, + `?callable $resolve = null`, so a write staged after other writes is judged as it will run, after them. + `$resolve(string $collection, string $id): ?Document` returns a related document as the earlier staged writes leave + it: the document with its permissions (whether or not the caller can read it), an empty `Document` when they delete + it, or `null` when they leave it as stored. Every related-document lookup of the checks uses it: a document staged + for deletion is new to the write and needs create on its collection, a staged update or create is judged by its + staged permissions and links, and a `null` answer reads the stored document as before. Without `$resolve` the + checks are unchanged, and they still write nothing. + ## 8.0.2 8.0.1 reverted six deliberate 8.0.0 changes as if they were regressions. 8.0.2 brings them back; everything else in diff --git a/UPGRADE.md b/UPGRADE.md index 46109389f..5ac2f735b 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -855,6 +855,12 @@ method as a no-op, so a hook overrides only what it needs: operations of a transaction another request commits, calls them at staging instead of repeating the checks. `authorizeUpdate()` checks the update against `$old`, the stored document as the caller reads it, and always needs update permission, where `updateDocument()` lets an update that changes nothing through with read permission. + Since 8.0.3 both take an optional `$resolve(string $collection, string $id): ?Document`, the related documents as + the writes staged earlier leave them: the document with its permissions, unfiltered by the caller's read, an + empty `Document` for one they delete, or `null` for one they leave as stored. Pass it when the staged write runs + after other staged writes, so a related document staged for deletion is judged as new (create on its collection) + and one staged for an update or create by its staged permissions and links, as the later write finds them. Pass + `$old` as the staged writes leave it too. - `deleteDocuments()` applies `Cascade` and `Restrict` to every related document, also when its queries include a select (7.x skipped the cascade and ignored `Restrict` then) and also to related documents the caller cannot read. Each document a cascade reaches is deleted through `deleteDocument()`, so one the caller may not delete throws diff --git a/src/Database/Hook/Relationships.php b/src/Database/Hook/Relationships.php index f69e92e83..1ce899a0c 100644 --- a/src/Database/Hook/Relationships.php +++ b/src/Database/Hook/Relationships.php @@ -3160,10 +3160,7 @@ private function authorizeLink(Document $collection, Document $related): void { $authorization = $this->database->getAuthorization(); - if (! $authorization->isValid(new Input(PermissionType::Update, [ - ...$collection->getPermissionsByType(PermissionType::Update), - ...($collection->getAttribute('documentSecurity', false) ? $related->getPermissionsByType(PermissionType::Update) : []), - ]))) { + if (! $authorization->isValid(self::permissionInput($collection, $related, PermissionType::Update))) { throw new AuthorizationException($authorization->getDescription()); } } @@ -3175,23 +3172,32 @@ private function authorizeLink(Document $collection, Document $related): void * document, and never a stored document the caller cannot read. With $old, the stored document an update * changes, a related document $old already links is not linked again, so only changing it needs update. * + * With $resolve, every related document is judged as a later write would find it: $resolve(collection, ID) + * returns the document as it will be when the write runs, with its permissions and without the caller's read + * applied, an empty document when it will be gone, or null to judge the stored one. + * + * @param (callable(string, string): ?Document)|null $resolve + * * @throws AuthorizationException When writing $document would be refused * @throws RelationshipException When a relationship value is not a document, an ID or a list of them * * @internal */ - public function authorize(Document $collection, Document $document, ?Document $old = null): void + public function authorize(Document $collection, Document $document, ?Document $old = null, ?callable $resolve = null): void { - $this->authorizeRelated($collection, $document, $old, []); + $this->authorizeRelated($collection, $document, $old, [], $resolve === null + ? null + : static fn (string $collection, string $id): ?Document => $resolve($collection, $id)); } /** * @param list $writeStack The collections of the writes relating $document, innermost last + * @param (Closure(string, string): ?Document)|null $resolve * * @throws AuthorizationException * @throws RelationshipException */ - private function authorizeRelated(Document $collection, Document $document, ?Document $old, array $writeStack): void + private function authorizeRelated(Document $collection, Document $document, ?Document $old, array $writeStack, ?Closure $resolve): void { $depth = \count($writeStack); @@ -3215,31 +3221,30 @@ private function authorizeRelated(Document $collection, Document $document, ?Doc foreach (self::relatedValues($value) as $related) { if (\is_string($related)) { - $this->authorizeLinkById($relatedCollection, $related, $linked); + $this->authorizeLinkById($relatedCollection, $related, $linked, $resolve); continue; } - $this->authorizeRelatedDocument($collection, $relatedCollection, $related, $linked, $writeStack); + $this->authorizeRelatedDocument($collection, $relatedCollection, $related, $linked, $writeStack, $resolve); } } } /** * @param list $linked + * @param (Closure(string, string): ?Document)|null $resolve * * @throws AuthorizationException */ - private function authorizeLinkById(Document $relatedCollection, string $id, array $linked): void + private function authorizeLinkById(Document $relatedCollection, string $id, array $linked, ?Closure $resolve): void { if (\in_array($id, $linked, true)) { return; } - $stored = $this->database->skipRelationships(fn () => $this->database->getDocument($relatedCollection->getId(), $id, [Query::select([Document::ID])])); + $stored = $this->viewRelated($relatedCollection, $id, PermissionType::Read, $resolve, [Query::select([Document::ID])]); if ($stored->isEmpty()) { - $this->refuseUnreadable($relatedCollection, $id, PermissionType::Read); - return; } @@ -3249,26 +3254,25 @@ private function authorizeLinkById(Document $relatedCollection, string $id, arra /** * @param list $linked * @param list $writeStack + * @param (Closure(string, string): ?Document)|null $resolve * * @throws AuthorizationException * @throws RelationshipException */ - private function authorizeRelatedDocument(Document $collection, Document $relatedCollection, Document $related, array $linked, array $writeStack): void + private function authorizeRelatedDocument(Document $collection, Document $relatedCollection, Document $related, array $linked, array $writeStack, ?Closure $resolve): void { $id = $related->getId(); $stored = $id === '' ? new Document() - : $this->database->skipRelationships(fn () => $this->database->getDocument($relatedCollection->getId(), $id)); + : $this->viewRelated($relatedCollection, $id, PermissionType::Update, $resolve); if ($stored->isEmpty()) { - $this->refuseUnreadable($relatedCollection, $id, PermissionType::Update); - $authorization = $this->database->getAuthorization(); if (! $authorization->isValid(new Input(PermissionType::Create, $relatedCollection->getPermissionsByType(PermissionType::Create)))) { throw new AuthorizationException($authorization->getDescription()); } - $this->authorizeRelated($relatedCollection, $related, null, [...$writeStack, $collection->getId()]); + $this->authorizeRelated($relatedCollection, $related, null, [...$writeStack, $collection->getId()], $resolve); return; } @@ -3278,7 +3282,36 @@ private function authorizeRelatedDocument(Document $collection, Document $relate $this->authorizeLink($relatedCollection, $stored); } - $this->authorizeRelated($relatedCollection, $related, $stored, [...$writeStack, $collection->getId()]); + $this->authorizeRelated($relatedCollection, $related, $stored, [...$writeStack, $collection->getId()], $resolve); + } + + /** + * The related document $id as a write would find it: the document $resolve gives for it, else the stored one, + * either empty when there is none. One the caller cannot read is refused for the reason of $check, or of read + * when the caller passes $check. + * + * @param (Closure(string, string): ?Document)|null $resolve + * @param array $queries The queries reading the stored document + * + * @throws AuthorizationException + */ + private function viewRelated(Document $collection, string $id, PermissionType $check, ?Closure $resolve, array $queries = []): Document + { + $staged = $resolve === null ? null : $resolve($collection->getId(), $id); + if ($staged === null) { + $stored = $this->database->skipRelationships(fn () => $this->database->getDocument($collection->getId(), $id, $queries)); + if ($stored->isEmpty()) { + $this->refuseUnreadable($collection, $id, $check); + } + + return $stored; + } + + if (! $staged->isEmpty() && ! $this->database->getAuthorization()->isValid(self::permissionInput($collection, $staged, PermissionType::Read))) { + $this->refuseHidden($collection, $staged, $check); + } + + return $staged; } /** @@ -3354,17 +3387,36 @@ private function refuseUnreadable(Document $collection, string $id, PermissionTy return; } - $documentSecurity = $collection->getAttribute('documentSecurity', false); + $this->refuseHidden($collection, $stored, $check); + } + + /** + * Refuse $hidden, a related document the caller cannot read, for the reason of $check, or of read when the + * caller passes $check. + * + * @throws AuthorizationException + */ + private function refuseHidden(Document $collection, Document $hidden, PermissionType $check): void + { + $authorization = $this->database->getAuthorization(); foreach ($check === PermissionType::Read ? [$check] : [$check, PermissionType::Read] as $type) { - if (! $authorization->isValid(new Input($type, [ - ...$collection->getPermissionsByType($type), - ...($documentSecurity ? $stored->getPermissionsByType($type) : []), - ]))) { + if (! $authorization->isValid(self::permissionInput($collection, $hidden, $type))) { throw new AuthorizationException($authorization->getDescription()); } } } + /** + * The permissions granting $type on $document: its collection's, and its own under document security. + */ + private static function permissionInput(Document $collection, Document $document, PermissionType $type): Input + { + return new Input($type, [ + ...$collection->getPermissionsByType($type), + ...($collection->getAttribute('documentSecurity', false) ? $document->getPermissionsByType($type) : []), + ]); + } + private function notReferencing(string $twoWayKey, string $documentId): Query { return Query::or([ diff --git a/src/Database/Trait/Documents.php b/src/Database/Trait/Documents.php index 85f07e328..16e43b7f3 100644 --- a/src/Database/Trait/Documents.php +++ b/src/Database/Trait/Documents.php @@ -1049,14 +1049,21 @@ public function applySelectFiltersToDocuments(array $documents, array $selectQue * refused. For staging a write that runs later, such as an operation of a transaction committed by another * request; createDocument() runs the same checks when it writes. * + * Writes staged before the later one change what it finds. With $resolve, every related document is judged as + * the later write would find it: $resolve(collection, ID) is given the collection identifier and the document ID + * and returns the document as the staged writes leave it, with its permissions and whether or not the caller can + * read it, an empty document when they delete it, or null when they leave it as stored. A related document the + * staged writes delete is new to the later write, so it needs create rather than update. + * * @param string $collection The collection identifier * @param Document $document The document a later createDocument() would be given; it is not changed + * @param (callable(string, string): ?Document)|null $resolve The staged view of a related document; null judges every one as stored * * @throws AuthorizationException When createDocument() would refuse the caller * @throws NotFoundException When the collection does not exist * @throws RelationshipException When a relationship value is not a document, an ID or a list of them */ - public function authorizeCreate(string $collection, Document $document): void + public function authorizeCreate(string $collection, Document $document, ?callable $resolve = null): void { $definition = $this->silent(fn () => $this->getCollection($collection)); @@ -1065,7 +1072,7 @@ public function authorizeCreate(string $collection, Document $document): void } if ($this->relationshipHook?->isEnabled()) { - $this->relationshipHook->authorize($definition, $document); + $this->relationshipHook->authorize($definition, $document, resolve: $resolve); } } @@ -1074,17 +1081,19 @@ public function authorizeCreate(string $collection, Document $document): void * collection, or on $old under document security) and, for every related document $document carries, what * authorizeCreate() checks for it. A related document $old already links needs no link check, only update when * it is sent as a document with attributes to change. Unlike updateDocument(), which lets an update that changes - * nothing through with read permission, this always needs update. + * nothing through with read permission, this always needs update. $resolve gives the staged view of related + * documents as for authorizeCreate(); $old is the document as the staged writes leave it. * * @param string $collection The collection identifier * @param Document $old The stored document the update changes, as the caller reads it * @param Document $document The changes a later updateDocument() would be given; it is not changed + * @param (callable(string, string): ?Document)|null $resolve The staged view of a related document; null judges every one as stored * * @throws AuthorizationException When updateDocument() would refuse the caller * @throws NotFoundException When the collection does not exist * @throws RelationshipException When a relationship value is not a document, an ID or a list of them */ - public function authorizeUpdate(string $collection, Document $old, Document $document): void + public function authorizeUpdate(string $collection, Document $old, Document $document, ?callable $resolve = null): void { $definition = $this->silent(fn () => $this->getCollection($collection)); @@ -1096,7 +1105,7 @@ public function authorizeUpdate(string $collection, Document $old, Document $doc } if ($this->relationshipHook?->isEnabled()) { - $this->relationshipHook->authorize($definition, $document, $old); + $this->relationshipHook->authorize($definition, $document, $old, $resolve); } } diff --git a/tests/unit/Relationships/StagedViewAuthorizationTest.php b/tests/unit/Relationships/StagedViewAuthorizationTest.php new file mode 100644 index 000000000..1e4adb57d --- /dev/null +++ b/tests/unit/Relationships/StagedViewAuthorizationTest.php @@ -0,0 +1,370 @@ +, list|null, list|null, string, ?string}> + */ + public static function cases(): iterable + { + $users = Role::users(); + $user = Role::user(self::USER); + $createRead = [Permission::create($users), Permission::read($users)]; + + $scenarios = [ + 'unstaged, update on the collection' => [self::UNSTAGED, false, [...$createRead, Permission::update($users)], [], null, null, null], + 'unstaged, read without update' => [self::UNSTAGED, true, $createRead, [], null, self::NO_UPDATE, self::NO_UPDATE], + 'staged deleted, create on the collection, update only for another team' => [self::DELETED, true, [Permission::create(Role::any()), Permission::read(Role::any()), Permission::delete(Role::any())], [Permission::update(Role::team('admin'))], null, null, null], + 'staged deleted, no create on the collection' => [self::DELETED, false, [Permission::read($users), Permission::update($users)], [], null, null, self::NO_CREATE], + 'staged update revokes update' => [self::UPDATED, true, $createRead, [Permission::update($user)], [], self::NO_UPDATE, self::NO_UPDATE], + 'staged update grants update' => [self::UPDATED, true, $createRead, [], [Permission::update($user)], null, null], + 'staged update hides it' => [self::UPDATED, true, [Permission::create($users)], [Permission::read($user), Permission::update($user)], [Permission::update($user)], self::NO_READ, self::NO_READ], + 'staged update reveals it' => [self::UPDATED, true, [Permission::create($users)], [], [Permission::read($user), Permission::update($user)], null, null], + 'staged create without update' => [self::CREATED, true, $createRead, null, [], self::NO_UPDATE, self::NO_UPDATE], + 'staged create with update' => [self::CREATED, true, $createRead, null, [Permission::update($user)], null, null], + ]; + + $adapters = [ + 'memory' => static fn (): Adapter => new Memory(), + 'sqlite' => static fn (): Adapter => new SQLite(new PDO('sqlite::memory:')), + ]; + + foreach ($adapters as $adapterName => $adapter) { + foreach (self::TYPES as $type) { + foreach ($scenarios as $scenario => [$state, $documentSecurity, $collectionPermissions, $storedPermissions, $stagedPermissions, $byId, $asDocument]) { + yield "{$adapterName}, {$type} by ID, {$scenario}" => [$adapter, $type, $state, $documentSecurity, $collectionPermissions, $storedPermissions, $stagedPermissions, self::ID, $byId]; + yield "{$adapterName}, {$type} as a document, {$scenario}" => [$adapter, $type, $state, $documentSecurity, $collectionPermissions, $storedPermissions, $stagedPermissions, self::DOCUMENT, $asDocument]; + } + } + } + } + + /** + * @param Closure(): Adapter $adapter + * @param list $collectionPermissions + * @param list|null $storedPermissions + * @param list|null $stagedPermissions + */ + #[DataProvider('cases')] + public function testAuthorizeCreateJudgesTheStagedView(Closure $adapter, string $type, string $state, bool $documentSecurity, array $collectionPermissions, ?array $storedPermissions, ?array $stagedPermissions, string $value, ?string $refusal): void + { + $this->seed($adapter(), $type, $documentSecurity, $collectionPermissions, $storedPermissions); + $resolve = $this->resolver($state, $stagedPermissions); + + $this->assertSame($refusal, $this->refusal(fn () => $this->asUser(fn () => $this->database->authorizeCreate('parents', $this->parent($type, $value), $resolve)))); + $this->assertWroteNothing($storedPermissions !== null); + } + + /** + * @param Closure(): Adapter $adapter + * @param list $collectionPermissions + * @param list|null $storedPermissions + * @param list|null $stagedPermissions + */ + #[DataProvider('cases')] + public function testTheCreateDecidesTheSameAfterTheStagedWrites(Closure $adapter, string $type, string $state, bool $documentSecurity, array $collectionPermissions, ?array $storedPermissions, ?array $stagedPermissions, string $value, ?string $refusal): void + { + $this->seed($adapter(), $type, $documentSecurity, $collectionPermissions, $storedPermissions); + + $this->assertSame($refusal, $this->refusal(fn () => $this->database->withTransaction(function () use ($state, $stagedPermissions, $type, $value): void { + $this->applyStaged($state, $stagedPermissions); + $this->asUser(fn () => $this->database->createDocument('parents', $this->parent($type, $value))); + }))); + } + + /** + * @param Closure(): Adapter $adapter + * @param list $collectionPermissions + * @param list|null $storedPermissions + * @param list|null $stagedPermissions + */ + #[DataProvider('cases')] + public function testAuthorizeUpdateJudgesTheStagedView(Closure $adapter, string $type, string $state, bool $documentSecurity, array $collectionPermissions, ?array $storedPermissions, ?array $stagedPermissions, string $value, ?string $refusal): void + { + $this->seed($adapter(), $type, $documentSecurity, $collectionPermissions, $storedPermissions); + $old = $this->storedParent(); + $resolve = $this->resolver($state, $stagedPermissions); + + $this->assertSame($refusal, $this->refusal(fn () => $this->asUser(fn () => $this->database->authorizeUpdate('parents', $old, $this->update($type, $value), $resolve)))); + $this->assertSame([], self::ids($this->authorization->skip(fn (): Document => $this->database->getDocument('parents', 'parent'))->getAttribute('related')), 'The check links nothing'); + } + + /** + * @param Closure(): Adapter $adapter + * @param list $collectionPermissions + * @param list|null $storedPermissions + * @param list|null $stagedPermissions + */ + #[DataProvider('cases')] + public function testTheUpdateDecidesTheSameAfterTheStagedWrites(Closure $adapter, string $type, string $state, bool $documentSecurity, array $collectionPermissions, ?array $storedPermissions, ?array $stagedPermissions, string $value, ?string $refusal): void + { + $this->seed($adapter(), $type, $documentSecurity, $collectionPermissions, $storedPermissions); + $this->storedParent(); + + $this->assertSame($refusal, $this->refusal(fn () => $this->database->withTransaction(function () use ($state, $stagedPermissions, $type, $value): void { + $this->applyStaged($state, $stagedPermissions); + $this->asUser(fn () => $this->database->updateDocument('parents', 'parent', $this->update($type, $value))); + }))); + } + + public function testARelatedDocumentStagedForDeletionIsRecreatedWithTheGivenPermissions(): void + { + $foreign = [Permission::update(Role::team('admin'))]; + $this->seed(new Memory(), 'oneToOne one-way', true, [Permission::create(Role::any()), Permission::read(Role::any()), Permission::delete(Role::any())], $foreign); + $parent = new Document(['$id' => 'parent', 'related' => ['$id' => 'stored', 'name' => 'Recreated', '$permissions' => $foreign]]); + + $this->assertSame(self::ONLY_ADMINS_UPDATE, $this->refusal(fn () => $this->asUser(fn () => $this->database->authorizeCreate('parents', $parent))), 'Judged as stored, the nested document is a link needing update'); + $this->assertNull($this->refusal(fn () => $this->asUser(fn () => $this->database->authorizeCreate('parents', $parent, $this->resolver(self::DELETED, null)))), 'Judged as staged, the nested document is new and needs create'); + + $this->database->withTransaction(function () use ($parent): void { + $this->applyStaged(self::DELETED, null); + $this->asUser(fn () => $this->database->createDocument('parents', $parent)); + }); + + $recreated = $this->authorization->skip(fn (): Document => $this->database->getDocument('related', 'stored')); + $this->assertSame('Recreated', $recreated->getAttribute('name')); + $this->assertSame($foreign, $recreated->getPermissions()); + } + + public function testAResolverReturningNullJudgesTheStoredDocument(): void + { + $this->seed(new Memory(), 'manyToOne', true, [Permission::create(Role::users()), Permission::read(Role::users())], [Permission::update(Role::user(self::USER))]); + + foreach ([self::ID, self::DOCUMENT] as $value) { + $this->assertNull($this->refusal(fn () => $this->asUser(fn () => $this->database->authorizeCreate('parents', $this->parent('manyToOne', $value), static fn (string $collection, string $id): ?Document => null)))); + } + } + + public function testAnApiKeyPassesWhateverTheStagedView(): void + { + $this->seed(new Memory(), 'oneToMany', true, [], []); + + foreach ([self::DELETED, self::UPDATED] as $state) { + $resolve = $this->resolver($state, []); + foreach ([self::ID, self::DOCUMENT] as $value) { + $this->assertNull($this->refusal(fn () => $this->authorization->skip(fn () => $this->database->authorizeCreate('parents', $this->parent('oneToMany', $value), $resolve)))); + } + } + } + + /** + * A document a staged write already links to the related document is not linked again, so only changing it + * needs update, as for the stored document an update changes. + */ + public function testANestedDocumentIsJudgedAgainstTheLinksTheStagedWritesLeave(): void + { + $this->seed(new Memory(), 'oneToMany', false, [Permission::create(Role::users()), Permission::read(Role::users()), Permission::update(Role::users())], []); + $this->authorization->skip(function (): void { + $this->database->createCollection(Collection::create('toys', attributes: [Attribute::string('name', size: 64)], permissions: [Permission::read(Role::users())])); + $this->database->createRelationship('related', Relationship::manyToOne('toys', 'toy', onDelete: RelationshipDeleteAction::SetNull)); + $this->database->createDocument('toys', new Document(['$id' => 'toy', 'name' => 'Toy'])); + }); + $parent = new Document(['$id' => 'parent', 'related' => [['$id' => 'stored', 'toy' => 'toy']]]); + $linked = fn (string $collection, string $id): ?Document => $collection === 'related' && $id === 'stored' + ? $this->authorization->skip(fn (): Document => $this->database->getDocument('related', 'stored'))->setAttribute('toy', 'toy') + : null; + + $this->assertSame(self::NO_UPDATE, $this->refusal(fn () => $this->asUser(fn () => $this->database->authorizeCreate('parents', $parent))), 'Judged as stored, the toy is linked anew'); + $this->assertNull($this->refusal(fn () => $this->asUser(fn () => $this->database->authorizeCreate('parents', $parent, $linked))), 'Judged as staged, the toy is already linked'); + + $this->database->withTransaction(function () use ($parent): void { + $this->authorization->skip(fn () => $this->database->updateDocument('related', 'stored', new Document(['toy' => 'toy']))); + $this->asUser(fn () => $this->database->createDocument('parents', $parent)); + }); + $this->assertSame(['stored'], self::ids($this->authorization->skip(fn (): Document => $this->database->getDocument('parents', 'parent'))->getAttribute('related'))); + } + + /** + * The view the staged writes of $state leave of the related document 'stored'. + * + * @param list|null $stagedPermissions + * @return Closure(string, string): ?Document + */ + private function resolver(string $state, ?array $stagedPermissions): Closure + { + return fn (string $collection, string $id): ?Document => $collection !== 'related' || $id !== 'stored' ? null : match ($state) { + self::DELETED => new Document(), + self::UPDATED, self::CREATED => new Document(['$id' => 'stored', '$collection' => 'related', 'name' => 'Stored', '$permissions' => $stagedPermissions ?? []]), + default => null, + }; + } + + /** + * @param list|null $stagedPermissions + */ + private function applyStaged(string $state, ?array $stagedPermissions): void + { + $this->authorization->skip(fn (): mixed => match ($state) { + self::DELETED => $this->database->deleteDocument('related', 'stored'), + self::UPDATED => $this->database->updateDocument('related', 'stored', new Document(['$permissions' => $stagedPermissions ?? []])), + self::CREATED => $this->database->createDocument('related', new Document(['$id' => 'stored', 'name' => 'Stored', '$permissions' => $stagedPermissions ?? []])), + default => null, + }); + } + + private function parent(string $type, string $value): Document + { + return new Document([ + '$id' => 'parent', + '$permissions' => [Permission::read(Role::any())], + 'related' => $this->related($type, $value), + ]); + } + + private function update(string $type, string $value): Document + { + return new Document(['related' => $this->related($type, $value)]); + } + + private function related(string $type, string $value): mixed + { + $related = $value === self::ID ? 'stored' : new Document(['$id' => 'stored', 'name' => 'Changed']); + + return \in_array($type, ['oneToMany', 'manyToMany'], true) ? [$related] : $related; + } + + private function storedParent(): Document + { + return $this->authorization->skip(fn (): Document => $this->database->createDocument('parents', new Document([ + '$id' => 'parent', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + ]))); + } + + private function refusal(callable $check): ?string + { + try { + $check(); + } catch (AuthorizationException $error) { + return $error->getMessage(); + } + + return null; + } + + private function assertWroteNothing(bool $stored): void + { + $this->assertTrue($this->authorization->skip(fn (): bool => $this->database->getDocument('parents', 'parent')->isEmpty()), 'The check writes no parent'); + $related = $this->authorization->skip(fn (): Document => $this->database->getDocument('related', 'stored')); + if (! $stored) { + $this->assertTrue($related->isEmpty(), 'The check creates no related document'); + + return; + } + $this->assertSame('Stored', $related->getAttribute('name'), 'The check changes no related document'); + $this->assertSame([], self::ids($related->getAttribute('back')), 'The check links nothing'); + } + + /** + * @param list $collectionPermissions + * @param list|null $storedPermissions The related document's permissions; null stores none + */ + private function seed(Adapter $adapter, string $type, bool $documentSecurity, array $collectionPermissions, ?array $storedPermissions): void + { + $this->authorization = new Authorization(); + $this->database = (new Database($adapter, new Cache(new None()))) + ->setAuthorization($this->authorization) + ->setDatabase('staged_view') + ->setNamespace('staged_view_'.\uniqid()); + $this->database->addHook(new Permissions()); + $this->database->addHook(new Relationships()); + + $this->authorization->skip(function () use ($type, $documentSecurity, $collectionPermissions, $storedPermissions): void { + $this->database->create(); + $this->database->createCollection(Collection::create( + 'parents', + attributes: [Attribute::string('name', size: 64)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any())], + )); + $this->database->createCollection(Collection::create( + 'related', + attributes: [Attribute::string('name', size: 64)], + permissions: $collectionPermissions, + documentSecurity: $documentSecurity, + )); + $this->database->createRelationship('parents', match ($type) { + 'oneToOne' => Relationship::oneToOne('related', 'related', twoWay: true, twoWayKey: 'back', onDelete: RelationshipDeleteAction::SetNull), + 'oneToOne one-way' => Relationship::oneToOne('related', 'related', onDelete: RelationshipDeleteAction::SetNull), + 'oneToMany' => Relationship::oneToMany('related', 'related', twoWay: true, twoWayKey: 'back', onDelete: RelationshipDeleteAction::SetNull), + 'manyToOne' => Relationship::manyToOne('related', 'related', twoWay: true, twoWayKey: 'back', onDelete: RelationshipDeleteAction::SetNull), + default => Relationship::manyToMany('related', 'related', twoWay: true, twoWayKey: 'back', onDelete: RelationshipDeleteAction::SetNull), + }); + if ($storedPermissions !== null) { + $this->database->createDocument('related', new Document(['$id' => 'stored', 'name' => 'Stored', '$permissions' => $storedPermissions])); + } + }); + } + + private function asUser(callable $callback): mixed + { + return $this->authorization->withRoles([Role::any()->toString(), Role::users()->toString(), Role::user(self::USER)->toString()], $callback); + } + + /** + * @return list + */ + private static function ids(mixed $value): array + { + $values = \is_array($value) ? $value : ($value === null ? [] : [$value]); + + return \array_values(\array_map(static fn (mixed $item): string => $item instanceof Document ? $item->getId() : (\is_string($item) ? $item : ''), $values)); + } +}