From 2ad8770d355ecd98d65949247b7ae21d042ca94d Mon Sep 17 00:00:00 2001 From: Claudear <262350598+claudear@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:43:27 +0000 Subject: [PATCH] fix: relate to an existing related document the caller cannot read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creating a document with a nested related document decided between creating and updating the related document by reading it back with `Database::getDocument()`. That read is permission checked, so a related document that already exists but is not readable by the current role came back empty, which is indistinguishable from one that does not exist. The library then inserted it, hit the unique `_uid` key and reported `Duplicate: Document already exists` from the adapter — the error is about a row the caller never asked to create and cannot see. Fall back to a permission-blind read when the first one comes back empty, so the existing document is related to instead of re-created. Permissions are still enforced: the update that follows requires update permission on the related document, so a caller without it now gets an authorization error rather than a duplicate key error. Fixes CLOUD-3QYD. Co-Authored-By: Claude Opus 5 --- src/Database/Database.php | 11 ++ .../e2e/Adapter/Scopes/RelationshipTests.php | 122 ++++++++++++++++++ 2 files changed, 133 insertions(+) diff --git a/src/Database/Database.php b/src/Database/Database.php index d581529fa..e46e556a5 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -6183,6 +6183,17 @@ private function relateDocuments( // Try to get the related document $related = $this->getDocument($relatedCollection->getId(), $relation->getId()); + if ($related->isEmpty() && !empty($relation->getId())) { + // A related document the caller cannot read comes back empty, which is + // indistinguishable from one that does not exist. Creating it would hit + // the unique _uid key and report "Document already exists", so read it + // again without permissions and relate to what is already there. The + // update below still enforces the caller's update permission. + $related = $this->authorization->skip( + fn () => $this->getDocument($relatedCollection->getId(), $relation->getId()) + ); + } + if ($related->isEmpty()) { // If the related document doesn't exist, create it, inheriting permissions if none are set if (!isset($relation['$permissions'])) { diff --git a/tests/e2e/Adapter/Scopes/RelationshipTests.php b/tests/e2e/Adapter/Scopes/RelationshipTests.php index dbfba7bfc..ff052af81 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -4930,4 +4930,126 @@ public function testOrderAndCursorWithRelationshipQueries(): void $database->deleteCollection('authorsOrder'); $database->deleteCollection('postsOrder'); } + + /** + * A nested related document that already exists but is not readable by the + * current role must be related to, not re-created. Creating it fails on the + * unique `_uid` key and surfaces a bare "Document already exists" duplicate + * error instead of linking the two documents. + */ + public function testCreateDocumentWithUnreadableExistingRelatedDocument(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + return; + } + + // No read permission anywhere on the child collection, so its documents + // exist but are invisible to the caller. + $database->createCollection('hiddenKeys', permissions: [ + Permission::create(Role::any()), + Permission::update(Role::any()), + ]); + $database->createCollection('hiddenSeals', permissions: [ + Permission::create(Role::any()), + ]); + $database->createCollection('hiddenVaults', permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ]); + + $database->createAttribute('hiddenKeys', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('hiddenSeals', 'name', Database::VAR_STRING, 255, true); + $database->createAttribute('hiddenVaults', 'name', Database::VAR_STRING, 255, true); + + $database->createRelationship( + collection: 'hiddenVaults', + relatedCollection: 'hiddenKeys', + type: Database::RELATION_ONE_TO_ONE, + twoWay: true, + id: 'key', + twoWayKey: 'vault' + ); + + $database->createRelationship( + collection: 'hiddenVaults', + relatedCollection: 'hiddenSeals', + type: Database::RELATION_ONE_TO_ONE, + twoWay: true, + id: 'seal', + twoWayKey: 'vault' + ); + + $database->createDocument('hiddenKeys', new Document([ + '$id' => 'hidden-key', + '$permissions' => [], + 'name' => 'Hidden Key', + ])); + + $database->createDocument('hiddenSeals', new Document([ + '$id' => 'hidden-seal', + '$permissions' => [], + 'name' => 'Hidden Seal', + ])); + + $this->assertTrue($database->getDocument('hiddenKeys', 'hidden-key')->isEmpty()); + $this->assertTrue($database->getDocument('hiddenSeals', 'hidden-seal')->isEmpty()); + + // The caller may update the child collection, so the existing document + // is related to the new parent instead of being re-created. + $vault = $database->createDocument('hiddenVaults', new Document([ + '$id' => 'vault-1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'Vault', + 'key' => new Document([ + '$id' => 'hidden-key', + 'name' => 'Hidden Key', + ]), + ])); + + $this->assertEquals('vault-1', $vault->getId()); + + $stored = $database->getAuthorization()->skip( + fn () => $database->getDocument('hiddenVaults', 'vault-1') + ); + + $this->assertEquals('hidden-key', $stored->getAttribute('key')->getId()); + $this->assertEquals('Hidden Key', $stored->getAttribute('key')->getAttribute('name')); + + $keys = $database->getAuthorization()->skip(fn () => $database->find('hiddenKeys')); + $this->assertCount(1, $keys); + + // Without update permission on the child collection the caller gets an + // authorization error, not a duplicate key error. + try { + $database->createDocument('hiddenVaults', new Document([ + '$id' => 'vault-2', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'Vault 2', + 'seal' => new Document([ + '$id' => 'hidden-seal', + 'name' => 'Broken Seal', + ]), + ])); + $this->fail('Failed to throw exception'); + } catch (AuthorizationException) { + } + + $seals = $database->getAuthorization()->skip(fn () => $database->find('hiddenSeals')); + $this->assertCount(1, $seals); + + $database->deleteCollection('hiddenVaults'); + $database->deleteCollection('hiddenKeys'); + $database->deleteCollection('hiddenSeals'); + } }