From c398e08f90ab660693c3626f4630090b761aef7f Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 22 Jul 2025 14:10:58 +0530 Subject: [PATCH] Add user injection and permission handling in Upsert actions --- .../Collections/Documents/Upsert.php | 26 ++- .../Http/Databases/Tables/Rows/Upsert.php | 1 + .../Databases/Legacy/DatabasesBase.php | 154 ++++++++++++++++++ .../Databases/Tables/DatabasesBase.php | 154 ++++++++++++++++++ 4 files changed, 330 insertions(+), 5 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php index 09e59c2ead..cac34a4fbd 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php @@ -77,13 +77,14 @@ class Upsert extends Action ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permissions strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) ->inject('requestTimestamp') ->inject('response') + ->inject('user') ->inject('dbForProject') ->inject('queueForEvents') ->inject('queueForStatsUsage') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $documentId, string|array $data, ?array $permissions, ?\DateTime $requestTimestamp, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, StatsUsage $queueForStatsUsage): void + public function action(string $databaseId, string $collectionId, string $documentId, string|array $data, ?array $permissions, ?\DateTime $requestTimestamp, UtopiaResponse $response, Document $user, Database $dbForProject, Event $queueForEvents, StatsUsage $queueForStatsUsage): void { $data = (\is_string($data)) ? \json_decode($data, true) : $data; // Cast to JSON array @@ -104,12 +105,28 @@ class Upsert extends Action throw new Exception($this->getParentNotFoundException()); } - // Map aggregate permissions into the multiple permissions they represent. - $permissions = Permission::aggregate($permissions, [ + $allowedPermissions = [ Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, - ]); + ]; + + $permissions = Permission::aggregate($permissions, $allowedPermissions); + // if no permission, upsert permission from the old document if present (update scenario) else add default permission (create scenario) + if (\is_null($permissions)) { + $oldDocument = Authorization::skip(fn () => $dbForProject->getDocument('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $documentId)); + if ($oldDocument->isEmpty()) { + if (!empty($user->getId())) { + $defaultPermissions = []; + foreach ($allowedPermissions as $permission) { + $defaultPermissions[] = (new Permission($permission, 'user', $user->getId()))->toString(); + } + $permissions = $defaultPermissions; + } + } else { + $permissions = $oldDocument->getPermissions(); + } + } // Users can only manage their own roles, API keys and Admin users can manage any $roles = Authorization::getRoles(); @@ -135,7 +152,6 @@ class Upsert extends Action $data['$id'] = $documentId; $data['$permissions'] = $permissions ?? []; $newDocument = new Document($data); - $operations = 0; $setCollection = (function (Document $collection, Document $document) use (&$setCollection, $dbForProject, $database, &$operations) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Tables/Rows/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Tables/Rows/Upsert.php index c442b40e0f..d6207a5c1f 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Tables/Rows/Upsert.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Tables/Rows/Upsert.php @@ -64,6 +64,7 @@ class Upsert extends DocumentUpsert ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permissions strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) ->inject('requestTimestamp') ->inject('response') + ->inject('user') ->inject('dbForProject') ->inject('queueForEvents') ->inject('queueForStatsUsage') diff --git a/tests/e2e/Services/Databases/Legacy/DatabasesBase.php b/tests/e2e/Services/Databases/Legacy/DatabasesBase.php index 12bf07e79c..c37615125e 100644 --- a/tests/e2e/Services/Databases/Legacy/DatabasesBase.php +++ b/tests/e2e/Services/Databases/Legacy/DatabasesBase.php @@ -2008,6 +2008,160 @@ trait DatabasesBase ])); $this->assertEquals(204, $deleteResponse['headers']['status-code']); + + if ($this->getSide() === 'client') { + // Skipped on server side: Creating a document with no permissions results in an empty permissions array, whereas on client side it assigns permissions to the current user + + // test without passing permissions + $document = $this->client->call(Client::METHOD_PUT, '/databases/' . $databaseId . '/collections/' . $data['moviesId'] . '/documents/' . $documentId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'data' => [ + 'title' => 'Thor: Ragnarok', + 'releaseYear' => 2000 + ] + ]); + + $this->assertEquals(200, $document['headers']['status-code']); + $this->assertEquals('Thor: Ragnarok', $document['body']['title']); + $this->assertCount(3, $document['body']['$permissions']); + $permissionsCreated = $document['body']['$permissions']; + // checking the default created permission + $defaultPermission = [ + Permission::read(Role::user($this->getUser()['$id'])), + Permission::update(Role::user($this->getUser()['$id'])), + Permission::delete(Role::user($this->getUser()['$id'])) + ]; + // ignoring the order of the permission and checking the permissions + $this->assertEqualsCanonicalizing($defaultPermission, $permissionsCreated); + + $document = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $data['moviesId'] . '/documents/' . $documentId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders())); + + $this->assertEquals(200, $document['headers']['status-code']); + + // updating the created doc + $document = $this->client->call(Client::METHOD_PUT, '/databases/' . $databaseId . '/collections/' . $data['moviesId'] . '/documents/' . $documentId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'data' => [ + 'title' => 'Thor: Ragnarok', + 'releaseYear' => 2002 + ] + ]); + $this->assertEquals(200, $document['headers']['status-code']); + $this->assertEquals('Thor: Ragnarok', $document['body']['title']); + $this->assertEquals(2002, $document['body']['releaseYear']); + $this->assertCount(3, $document['body']['$permissions']); + $this->assertEquals($permissionsCreated, $document['body']['$permissions']); + + // removing the delete permission + $document = $this->client->call(Client::METHOD_PUT, '/databases/' . $databaseId . '/collections/' . $data['moviesId'] . '/documents/' . $documentId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'data' => [ + 'title' => 'Thor: Ragnarok', + 'releaseYear' => 2002 + ], + 'permissions' => [ + Permission::update(Role::user($this->getUser()['$id'])) + ] + ]); + $this->assertEquals(200, $document['headers']['status-code']); + $this->assertEquals('Thor: Ragnarok', $document['body']['title']); + $this->assertEquals(2002, $document['body']['releaseYear']); + $this->assertCount(1, $document['body']['$permissions']); + + $deleteResponse = $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId . '/collections/' . $data['moviesId'] . '/documents/' . $documentId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders())); + + $this->assertEquals(401, $deleteResponse['headers']['status-code']); + + // giving the delete permission + $document = $this->client->call(Client::METHOD_PUT, '/databases/' . $databaseId . '/collections/' . $data['moviesId'] . '/documents/' . $documentId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'data' => [ + 'title' => 'Thor: Ragnarok', + 'releaseYear' => 2002 + ], + 'permissions' => [ + Permission::update(Role::user($this->getUser()['$id'])), + Permission::delete(Role::user($this->getUser()['$id'])) + ] + ]); + $this->assertEquals(200, $document['headers']['status-code']); + $this->assertEquals('Thor: Ragnarok', $document['body']['title']); + $this->assertEquals(2002, $document['body']['releaseYear']); + $this->assertCount(2, $document['body']['$permissions']); + + $deleteResponse = $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId . '/collections/' . $data['moviesId'] . '/documents/' . $documentId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders())); + + $this->assertEquals(204, $deleteResponse['headers']['status-code']); + + // upsertion for the related document without passing permissions + // data should get added + $newPersonId = ID::unique(); + $personNoPerm = $this->client->call(Client::METHOD_PUT, '/databases/' . $databaseId . '/collections/' . $person['body']['$id'] . '/documents/' . $newPersonId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'data' => [ + 'library' => [ + '$id' => 'library3', + 'libraryName' => 'Library 3', + ], + ], + ]); + + $this->assertEquals('Library 3', $personNoPerm['body']['library']['libraryName']); + $this->assertCount(3, $personNoPerm['body']['library']['$permissions']); + $this->assertCount(3, $personNoPerm['body']['$permissions']); + $documents = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $person['body']['$id'] . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::select(['fullName', 'library.*'])->toString() + ], + ]); + $this->assertGreaterThanOrEqual(1, $documents['body']['total']); + $documentsDetails = $documents['body']['documents']; + foreach ($documentsDetails as $doc) { + $this->assertCount(3, $doc['$permissions']); + } + $found = false; + foreach ($documents['body']['documents'] as $doc) { + if (isset($doc['library']['libraryName']) && $doc['library']['libraryName'] === 'Library 3') { + $found = true; + break; + } + } + $this->assertTrue($found, 'Library 3 should be present in the upserted documents.'); + + // Fetch the related library and assert on its permissions (should be default/inherited) + $library3 = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $library['body']['$id'] . '/documents/library3', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $library3['headers']['status-code']); + $this->assertEquals('Library 3', $library3['body']['libraryName']); + $this->assertArrayHasKey('$permissions', $library3['body']); + $this->assertCount(3, $library3['body']['$permissions']); + $this->assertNotEmpty($library3['body']['$permissions']); + } } /** diff --git a/tests/e2e/Services/Databases/Tables/DatabasesBase.php b/tests/e2e/Services/Databases/Tables/DatabasesBase.php index ad1d84eee1..58de6754ef 100644 --- a/tests/e2e/Services/Databases/Tables/DatabasesBase.php +++ b/tests/e2e/Services/Databases/Tables/DatabasesBase.php @@ -1997,6 +1997,160 @@ trait DatabasesBase ])); $this->assertEquals(204, $deleteResponse['headers']['status-code']); + + if ($this->getSide() === 'client') { + // Skipped on server side: Creating a document with no permissions results in an empty permissions array, whereas on client side it assigns permissions to the current user + + // test without passing permissions + $document = $this->client->call(Client::METHOD_PUT, '/databases/' . $databaseId . '/collections/' . $data['moviesId'] . '/documents/' . $rowId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'data' => [ + 'title' => 'Thor: Ragnarok', + 'releaseYear' => 2000 + ] + ]); + + $this->assertEquals(200, $document['headers']['status-code']); + $this->assertEquals('Thor: Ragnarok', $document['body']['title']); + $this->assertCount(3, $document['body']['$permissions']); + $permissionsCreated = $document['body']['$permissions']; + // checking the default created permission + $defaultPermission = [ + Permission::read(Role::user($this->getUser()['$id'])), + Permission::update(Role::user($this->getUser()['$id'])), + Permission::delete(Role::user($this->getUser()['$id'])) + ]; + // ignoring the order of the permission and checking the permissions + $this->assertEqualsCanonicalizing($defaultPermission, $permissionsCreated); + + $document = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $data['moviesId'] . '/documents/' . $rowId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders())); + + $this->assertEquals(200, $document['headers']['status-code']); + + // updating the created doc + $document = $this->client->call(Client::METHOD_PUT, '/databases/' . $databaseId . '/collections/' . $data['moviesId'] . '/documents/' . $rowId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'data' => [ + 'title' => 'Thor: Ragnarok', + 'releaseYear' => 2002 + ] + ]); + $this->assertEquals(200, $document['headers']['status-code']); + $this->assertEquals('Thor: Ragnarok', $document['body']['title']); + $this->assertEquals(2002, $document['body']['releaseYear']); + $this->assertCount(3, $document['body']['$permissions']); + $this->assertEquals($permissionsCreated, $document['body']['$permissions']); + + // removing the delete permission + $document = $this->client->call(Client::METHOD_PUT, '/databases/' . $databaseId . '/collections/' . $data['moviesId'] . '/documents/' . $rowId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'data' => [ + 'title' => 'Thor: Ragnarok', + 'releaseYear' => 2002 + ], + 'permissions' => [ + Permission::update(Role::user($this->getUser()['$id'])) + ] + ]); + $this->assertEquals(200, $document['headers']['status-code']); + $this->assertEquals('Thor: Ragnarok', $document['body']['title']); + $this->assertEquals(2002, $document['body']['releaseYear']); + $this->assertCount(1, $document['body']['$permissions']); + + $deleteResponse = $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId . '/collections/' . $data['moviesId'] . '/documents/' . $rowId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders())); + + $this->assertEquals(401, $deleteResponse['headers']['status-code']); + + // giving the delete permission + $document = $this->client->call(Client::METHOD_PUT, '/databases/' . $databaseId . '/collections/' . $data['moviesId'] . '/documents/' . $rowId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'data' => [ + 'title' => 'Thor: Ragnarok', + 'releaseYear' => 2002 + ], + 'permissions' => [ + Permission::update(Role::user($this->getUser()['$id'])), + Permission::delete(Role::user($this->getUser()['$id'])) + ] + ]); + $this->assertEquals(200, $document['headers']['status-code']); + $this->assertEquals('Thor: Ragnarok', $document['body']['title']); + $this->assertEquals(2002, $document['body']['releaseYear']); + $this->assertCount(2, $document['body']['$permissions']); + + $deleteResponse = $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId . '/collections/' . $data['moviesId'] . '/documents/' . $rowId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders())); + + $this->assertEquals(204, $deleteResponse['headers']['status-code']); + + // upsertion for the related document without passing permissions + // data should get added + $newPersonId = ID::unique(); + $personNoPerm = $this->client->call(Client::METHOD_PUT, '/databases/' . $databaseId . '/collections/' . $person['body']['$id'] . '/documents/' . $newPersonId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'data' => [ + 'library' => [ + '$id' => 'library3', + 'libraryName' => 'Library 3', + ], + ], + ]); + + $this->assertEquals('Library 3', $personNoPerm['body']['library']['libraryName']); + $this->assertCount(3, $personNoPerm['body']['library']['$permissions']); + $this->assertCount(3, $personNoPerm['body']['$permissions']); + $documents = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $person['body']['$id'] . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::select(['fullName', 'library.*'])->toString() + ], + ]); + $this->assertGreaterThanOrEqual(1, $documents['body']['total']); + $documentsDetails = $documents['body']['documents']; + foreach ($documentsDetails as $doc) { + $this->assertCount(3, $doc['$permissions']); + } + $found = false; + foreach ($documents['body']['documents'] as $doc) { + if (isset($doc['library']['libraryName']) && $doc['library']['libraryName'] === 'Library 3') { + $found = true; + break; + } + } + $this->assertTrue($found, 'Library 3 should be present in the upserted documents.'); + + // Fetch the related library and assert on its permissions (should be default/inherited) + $library3 = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $library['body']['$id'] . '/documents/library3', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $library3['headers']['status-code']); + $this->assertEquals('Library 3', $library3['body']['libraryName']); + $this->assertArrayHasKey('$permissions', $library3['body']); + $this->assertCount(3, $library3['body']['$permissions']); + $this->assertNotEmpty($library3['body']['$permissions']); + } } /**