diff --git a/app/controllers/api/database.php b/app/controllers/api/database.php index 255293c040..f82219c1eb 100644 --- a/app/controllers/api/database.php +++ b/app/controllers/api/database.php @@ -15,6 +15,7 @@ use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Query; use Utopia\Database\Adapter\MariaDB; +use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\Key; use Utopia\Database\Validator\Permissions; use Utopia\Database\Validator\QueryValidator; @@ -31,7 +32,6 @@ use Appwrite\Network\Validator\IP; use Appwrite\Network\Validator\URL; use Appwrite\Utopia\Response; use DeviceDetector\DeviceDetector; -use Utopia\Database\Validator\Authorization; /** * Create attribute of varying type @@ -1459,13 +1459,28 @@ App::post('/v1/database/collections/:collectionId/documents') throw new Exception('Collection not found', 404); } + // Check collection permissions when enforced + if ($collection->getAttribute('permission') === 'collection') { + $validator = new Authorization('write'); + if (!$validator->isValid($collection->getWrite())) { + throw new Exception('Unauthorized permissions', 401); + } + } + $data['$collection'] = $collection->getId(); // Adding this param to make API easier for developers $data['$id'] = $documentId == 'unique()' ? $dbForExternal->getId() : $documentId; $data['$read'] = (is_null($read) && !$user->isEmpty()) ? ['user:'.$user->getId()] : $read ?? []; // By default set read permissions for user $data['$write'] = (is_null($write) && !$user->isEmpty()) ? ['user:'.$user->getId()] : $write ?? []; // By default set write permissions for user try { - $document = $dbForExternal->createDocument($collectionId, new Document($data)); + if ($collection->getAttribute('permission') === 'collection') { + /** @var Document $document */ + $document = Authorization::skip(function() use ($dbForExternal, $collectionId, $data) { + return $dbForExternal->createDocument($collectionId, new Document($data)); + }); + } else { + $document = $dbForExternal->createDocument($collectionId, new Document($data)); + } } catch (StructureException $exception) { throw new Exception($exception->getMessage(), 400); @@ -1523,6 +1538,14 @@ App::get('/v1/database/collections/:collectionId/documents') throw new Exception('Collection not found', 404); } + // Check collection permissions when enforced + if ($collection->getAttribute('permission') === 'collection') { + $validator = new Authorization('read'); + if (!$validator->isValid($collection->getRead())) { + throw new Exception('Unauthorized permissions', 401); + } + } + $queries = \array_map(function ($query) { return Query::parse($query); }, $queries); @@ -1542,6 +1565,15 @@ App::get('/v1/database/collections/:collectionId/documents') } } + if ($collection->getAttribute('permission') === 'collection') { + /** @var Document[] $documents */ + $documents = Authorization::skip(function() use ($dbForExternal, $collectionId, $queries, $limit, $offset, $orderAttributes, $orderTypes, $afterDocument) { + return $dbForExternal->find($collectionId, $queries, $limit, $offset, $orderAttributes, $orderTypes, $afterDocument ?? null); + }); + } else { + $documents = $dbForExternal->find($collectionId, $queries, $limit, $offset, $orderAttributes, $orderTypes, $afterDocument ?? null); + } + $usage ->setParam('database.documents.read', 1) ->setParam('collectionId', $collectionId) @@ -1549,7 +1581,7 @@ App::get('/v1/database/collections/:collectionId/documents') $response->dynamic(new Document([ 'sum' => $dbForExternal->count($collectionId, $queries, APP_LIMIT_COUNT), - 'documents' => $dbForExternal->find($collectionId, $queries, $limit, $offset, $orderAttributes, $orderTypes, $afterDocument ?? null), + 'documents' => $documents, ]), Response::MODEL_DOCUMENT_LIST); }); @@ -1581,7 +1613,22 @@ App::get('/v1/database/collections/:collectionId/documents/:documentId') throw new Exception('Collection not found', 404); } - $document = $dbForExternal->getDocument($collectionId, $documentId); + // Check collection permissions when enforced + if ($collection->getAttribute('permission') === 'collection') { + $validator = new Authorization('read'); + if (!$validator->isValid($collection->getRead())) { + throw new Exception('Unauthorized permissions', 401); + } + } + + if ($collection->getAttribute('permission') === 'collection') { + /** @var Document $document */ + $document = Authorization::skip(function() use ($dbForExternal, $collectionId, $documentId) { + return $dbForExternal->getDocument($collectionId, $documentId); + }); + } else { + $document = $dbForExternal->getDocument($collectionId, $documentId); + } if ($document->isEmpty()) { throw new Exception('No document found', 404); @@ -1630,6 +1677,14 @@ App::patch('/v1/database/collections/:collectionId/documents/:documentId') throw new Exception('Collection not found', 404); } + // Check collection permissions when enforced + if ($collection->getAttribute('permission') === 'collection') { + $validator = new Authorization('write'); + if (!$validator->isValid($collection->getWrite())) { + throw new Exception('Unauthorized permissions', 401); + } + } + $document = $dbForExternal->getDocument($collectionId, $documentId); if ($document->isEmpty()) { @@ -1654,7 +1709,14 @@ App::patch('/v1/database/collections/:collectionId/documents/:documentId') $data['$write'] = (is_null($write)) ? ($document->getWrite() ?? []) : $write; // By default inherit write permissions try { - $document = $dbForExternal->updateDocument($collection->getId(), $document->getId(), new Document($data)); + if ($collection->getAttribute('permission') === 'collection') { + /** @var Document $document */ + $document = Authorization::skip(function() use ($dbForExternal, $collection, $document, $data) { + return $dbForExternal->updateDocument($collection->getId(), $document->getId(), new Document($data)); + }); + } else { + $document = $dbForExternal->updateDocument($collection->getId(), $document->getId(), new Document($data)); + } } catch (AuthorizationException $exception) { throw new Exception('Unauthorized permissions', 401); @@ -1712,7 +1774,22 @@ App::delete('/v1/database/collections/:collectionId/documents/:documentId') throw new Exception('Collection not found', 404); } - $document = $dbForExternal->getDocument($collectionId, $documentId); + // Check collection permissions when enforced + if ($collection->getAttribute('permission') === 'collection') { + $validator = new Authorization('write'); + if (!$validator->isValid($collection->getWrite())) { + throw new Exception('Unauthorized permissions', 401); + } + } + + if ($collection->getAttribute('permission') === 'collection') { + /** @var Document $document */ + $document = Authorization::skip(function() use ($dbForExternal, $collectionId, $documentId) { + return $dbForExternal->getDocument($collectionId, $documentId); + }); + } else { + $document = $dbForExternal->getDocument($collectionId, $documentId); + } if ($document->isEmpty()) { throw new Exception('No document found', 404); diff --git a/tests/e2e/Services/Database/DatabaseBase.php b/tests/e2e/Services/Database/DatabaseBase.php index f0c3d4e7b3..0e267b4be7 100644 --- a/tests/e2e/Services/Database/DatabaseBase.php +++ b/tests/e2e/Services/Database/DatabaseBase.php @@ -1167,7 +1167,7 @@ trait DatabaseBase // $this->assertEquals('Minimum value must be lesser than maximum value', $invalidRange['body']['message']); // wait for worker to add attributes - sleep(2); + sleep(3); $collection = $this->client->call(Client::METHOD_GET, '/database/collections/' . $collectionId, array_merge([ 'content-type' => 'application/json', @@ -1503,6 +1503,144 @@ trait DatabaseBase return $data; } + + public function testEnforceCollectionPermissions() + { + $user = 'user:' . $this->getUser()['$id']; + $collection = $this->client->call(Client::METHOD_POST, '/database/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => 'unique()', + 'name' => 'enforceCollectionPermissions', + 'permission' => 'collection', + 'read' => [$user], + 'write' => [$user] + ]); + + $this->assertEquals($collection['headers']['status-code'], 201); + $this->assertEquals($collection['body']['name'], 'enforceCollectionPermissions'); + $this->assertEquals($collection['body']['permission'], 'collection'); + + $collectionId = $collection['body']['$id']; + + sleep(2); + + $attribute = $this->client->call(Client::METHOD_POST, '/database/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'attributeId' => 'attribute', + 'size' => 64, + 'required' => true, + ]); + + $this->assertEquals(201, $attribute['headers']['status-code'], 201); + $this->assertEquals('attribute', $attribute['body']['key']); + + // wait for db to add attribute + sleep(2); + + $index = $this->client->call(Client::METHOD_POST, '/database/collections/' . $collectionId . '/indexes', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'indexId' => 'key_attribute', + 'type' => 'key', + 'attributes' => [$attribute['body']['key']], + ]); + + $this->assertEquals(201, $index['headers']['status-code']); + $this->assertEquals('key_attribute', $index['body']['key']); + + // wait for db to add attribute + sleep(2); + + $document1 = $this->client->call(Client::METHOD_POST, '/database/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documentId' => 'unique()', + 'data' => [ + 'attribute' => 'one', + ], + 'read' => [$user], + 'write' => [$user], + ]); + + $this->assertEquals(201, $document1['headers']['status-code']); + + $documents = $this->client->call(Client::METHOD_GET, '/database/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(1, $documents['body']['sum']); + $this->assertCount(1, $documents['body']['documents']); + + /* + * Test for Failure + */ + + // Remove write permission + $collection = $this->client->call(Client::METHOD_PUT, '/database/collections/' . $collectionId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'name' => 'enforceCollectionPermissions', + 'permission' => 'collection', + 'read' => [$user], + 'write' => [] + ]); + + $this->assertEquals(200, $collection['headers']['status-code']); + + $badDocument = $this->client->call(Client::METHOD_POST, '/database/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documentId' => 'unique()', + 'data' => [ + 'attribute' => 'bad', + ], + 'read' => [$user], + 'write' => [$user], + ]); + + if($this->getSide() == 'client') { + $this->assertEquals(401, $badDocument['headers']['status-code']); + } + + if($this->getSide() == 'server') { + $this->assertEquals(201, $badDocument['headers']['status-code']); + } + + // Remove read permission + $collection = $this->client->call(Client::METHOD_PUT, '/database/collections/' . $collectionId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'name' => 'enforceCollectionPermissions', + 'permission' => 'collection', + 'read' => [], + 'write' => [] + ]); + + $this->assertEquals(200, $collection['headers']['status-code']); + + $documents = $this->client->call(Client::METHOD_GET, '/database/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ])); + + $this->assertEquals(404, $documents['headers']['status-code']); + } + /** * @depends testDefaultPermissions */