From a9b117fc50fe9fc4621802fa7390f5e9a0ce7961 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Mon, 12 Oct 2026 18:51:24 +1300 Subject: [PATCH 1/2] feat(relationships): judge staging pre-checks against a staged view A transaction stages its operations in separate requests and commits them later, so the write a pre-check judges runs after the operations staged before it. authorizeCreate() and authorizeUpdate() read related documents from storage, so they judged a document the transaction already deletes as a stored link needing update, and a document it already creates, changes or relinks by its stored permissions and links. The pre-check then refused writes the commit allows and allowed writes the commit refuses. Both now take an optional resolver that gives a related document as the staged operations leave it: an empty document when they delete it (so the nested document is new and needs create on its collection), the staged document when they create or change it (judged by its permissions, readability and links), or null to read the stored one. Every related-document lookup of the pre-check goes through it. Without a resolver the checks are unchanged. The tests stage deletes, updates and creates for every relationship type and assert the pre-check decides what the write decides after the staged writes have run (263 of them fail without the resolver). Co-Authored-By: Claude Opus 5.5 --- src/Database/Hook/Relationships.php | 100 +++-- src/Database/Trait/Documents.php | 19 +- .../StagedViewAuthorizationTest.php | 370 ++++++++++++++++++ 3 files changed, 460 insertions(+), 29 deletions(-) create mode 100644 tests/unit/Relationships/StagedViewAuthorizationTest.php 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)); + } +} From a611413f1fd46b3217746d24c366298147797eef Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Mon, 12 Oct 2026 18:51:24 +1300 Subject: [PATCH 2/2] docs: describe the staged-view resolver of the pre-checks for 8.0.3 Applications staging writes need to know what the resolver returns for deleted, changed and untouched documents and that $old follows the same staged view. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 13 +++++++++++++ UPGRADE.md | 6 ++++++ 2 files changed, 19 insertions(+) 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