diff --git a/app/config/scopes/organization.php b/app/config/scopes/organization.php index 8d85662652..228a1437f2 100644 --- a/app/config/scopes/organization.php +++ b/app/config/scopes/organization.php @@ -3,13 +3,6 @@ // List of scopes for organization (teams) API keys return [ - "platforms.read" => [ - "description" => 'Access to read project\'s platforms', - ], - "platforms.write" => [ - "description" => - 'Access to create, update, and delete project\'s platforms', - ], "projects.read" => [ "description" => 'Access to read organization\'s projects', ], @@ -17,13 +10,6 @@ return [ "description" => "Access to create, update, and delete projects in organization", ], - "keys.read" => [ - "description" => 'Access to read project\'s API keys', - ], - "keys.write" => [ - "description" => - "Access to create, update, and delete project\'s API keys", - ], "devKeys.read" => [ "description" => 'Access to read project\'s development keys', ], diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index f5d8461aff..6c7f75c08e 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -188,4 +188,20 @@ return [ // List of publicly visible scopes "description" => "Access to update project\'s information", ], + "keys.read" => [ + "description" => + "Access to read project\'s keys", + ], + "keys.write" => [ + "description" => + "Access to create, update, and delete project\'s keys", + ], + "platforms.read" => [ + "description" => + "Access to read project\'s platforms", + ], + "platforms.write" => [ + "description" => + "Access to create, update, and delete project\'s platforms", + ], ]; diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index eb9ea59e2f..dac6ed456a 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -5,28 +5,18 @@ use Appwrite\Auth\Validator\MockNumber; use Appwrite\Event\Delete; use Appwrite\Event\Mail; use Appwrite\Extend\Exception; -use Appwrite\Network\Platform; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Deprecated; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Template\Template; -use Appwrite\Utopia\Database\Validator\CustomId; use Appwrite\Utopia\Database\Validator\Queries\Keys; use Appwrite\Utopia\Response; use PHPMailer\PHPMailer\PHPMailer; use Utopia\Config\Config; use Utopia\Database\Database; use Utopia\Database\Document; -use Utopia\Database\Exception\Duplicate; -use Utopia\Database\Exception\Query as QueryException; -use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; -use Utopia\Database\Query; -use Utopia\Database\Validator\Datetime as DatetimeValidator; -use Utopia\Database\Validator\Query\Cursor; use Utopia\Database\Validator\UID; use Utopia\Emails\Validator\Email; use Utopia\Http\Http; @@ -769,288 +759,6 @@ Http::delete('/v1/projects/:projectId') $response->noContent(); }); -// Keys - -Http::post('/v1/projects/:projectId/keys') - ->desc('Create key') - ->groups(['api', 'projects']) - ->label('scope', 'keys.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'keys', - name: 'createKey', - description: '/docs/references/projects/create-key.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_CREATED, - model: Response::MODEL_KEY, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - // TODO: When migrating to Platform API, mark keyId required for consistency - ->param('keyId', 'unique()', fn (Database $dbForPlatform) => new CustomId($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', true, ['dbForPlatform'])->param('name', null, new Text(128), 'Key name. Max length: 128 chars.') - ->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') - ->param('expire', null, new Nullable(new DatetimeValidator()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $keyId, string $name, array $scopes, ?string $expire, Response $response, Database $dbForPlatform) { - $keyId = $keyId == 'unique()' ? ID::unique() : $keyId; - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $key = new Document([ - '$id' => $keyId, - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'resourceInternalId' => $project->getSequence(), - 'resourceId' => $project->getId(), - 'resourceType' => 'projects', - 'name' => $name, - 'scopes' => $scopes, - 'expire' => $expire, - 'sdks' => [], - 'accessedAt' => null, - 'secret' => API_KEY_STANDARD . '_' . \bin2hex(\random_bytes(128)), - ]); - - try { - $key = $dbForPlatform->createDocument('keys', $key); - } catch (Duplicate) { - throw new Exception(Exception::KEY_ALREADY_EXISTS); - } - - $dbForPlatform->purgeCachedDocument('projects', $project->getId()); - - $response - ->setStatusCode(Response::STATUS_CODE_CREATED) - ->dynamic($key, Response::MODEL_KEY); - }); - -Http::get('/v1/projects/:projectId/keys') - ->desc('List keys') - ->groups(['api', 'projects']) - ->label('scope', 'keys.read') - ->label('sdk', new Method( - namespace: 'projects', - group: 'keys', - name: 'listKeys', - description: '/docs/references/projects/list-keys.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_KEY_LIST, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('queries', [], new Keys(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Keys::ALLOWED_ATTRIBUTES), true) - ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, array $queries, bool $includeTotal, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - try { - $queries = Query::parseQueries($queries); - } catch (QueryException $e) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); - } - - // Backwards compatibility - if (\count(Query::getByType($queries, [Query::TYPE_LIMIT])) === 0) { - $queries[] = Query::limit(5000); - } - - $queries[] = Query::equal('resourceType', ['projects']); - $queries[] = Query::equal('resourceInternalId', [$project->getSequence()]); - - $cursor = Query::getCursorQueries($queries, false); - $cursor = \reset($cursor); - - if ($cursor !== false) { - $validator = new Cursor(); - if (!$validator->isValid($cursor)) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); - } - - $keyId = $cursor->getValue(); - $cursorDocument = $dbForPlatform->getDocument('keys', $keyId); - - if ($cursorDocument->isEmpty()) { - throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Key '{$keyId}' for the 'cursor' value not found."); - } - - $cursor->setValue($cursorDocument); - } - - $filterQueries = Query::groupByType($queries)['filters']; - - $keys = $dbForPlatform->find('keys', $queries); - - $response->dynamic(new Document([ - 'keys' => $keys, - 'total' => $includeTotal ? $dbForPlatform->count('keys', $filterQueries, APP_LIMIT_COUNT) : 0, - ]), Response::MODEL_KEY_LIST); - }); - -Http::get('/v1/projects/:projectId/keys/:keyId') - ->desc('Get key') - ->groups(['api', 'projects']) - ->label('scope', 'keys.read') - ->label('sdk', new Method( - namespace: 'projects', - group: 'keys', - name: 'getKey', - description: '/docs/references/projects/get-key.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_KEY, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key unique ID.', false, ['dbForPlatform']) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $keyId, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $key = $dbForPlatform->findOne('keys', [ - Query::equal('$id', [$keyId]), - Query::equal('resourceType', ['projects']), - Query::equal('resourceInternalId', [$project->getSequence()]), - ]); - - if ($key->isEmpty()) { - throw new Exception(Exception::KEY_NOT_FOUND); - } - - $response->dynamic($key, Response::MODEL_KEY); - }); - -Http::put('/v1/projects/:projectId/keys/:keyId') - ->desc('Update key') - ->groups(['api', 'projects']) - ->label('scope', 'keys.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'keys', - name: 'updateKey', - description: '/docs/references/projects/update-key.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_KEY, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key unique ID.', false, ['dbForPlatform']) - ->param('name', null, new Text(128), 'Key name. Max length: 128 chars.') - ->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' events are allowed.') - ->param('expire', null, new Nullable(new DatetimeValidator()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $keyId, string $name, array $scopes, ?string $expire, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $key = $dbForPlatform->findOne('keys', [ - Query::equal('$id', [$keyId]), - Query::equal('resourceType', ['projects']), - Query::equal('resourceInternalId', [$project->getSequence()]), - ]); - - if ($key->isEmpty()) { - throw new Exception(Exception::KEY_NOT_FOUND); - } - - $key - ->setAttribute('name', $name) - ->setAttribute('scopes', $scopes) - ->setAttribute('expire', $expire); - - $dbForPlatform->updateDocument('keys', $key->getId(), $key); - - $dbForPlatform->purgeCachedDocument('projects', $project->getId()); - - $response->dynamic($key, Response::MODEL_KEY); - }); - -Http::delete('/v1/projects/:projectId/keys/:keyId') - ->desc('Delete key') - ->groups(['api', 'projects']) - ->label('scope', 'keys.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'keys', - name: 'deleteKey', - description: '/docs/references/projects/delete-key.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, - ) - ], - contentType: ContentType::NONE - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key unique ID.', false, ['dbForPlatform']) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $keyId, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $key = $dbForPlatform->findOne('keys', [ - Query::equal('$id', [$keyId]), - Query::equal('resourceType', ['projects']), - Query::equal('resourceInternalId', [$project->getSequence()]), - ]); - - if ($key->isEmpty()) { - throw new Exception(Exception::KEY_NOT_FOUND); - } - - $dbForPlatform->deleteDocument('keys', $key->getId()); - - $dbForPlatform->purgeCachedDocument('projects', $project->getId()); - - $response->noContent(); - }); - // JWT Keys Http::post('/v1/projects/:projectId/jwts') diff --git a/composer.lock b/composer.lock index 813dfe3c1d..90e8a09ab2 100644 --- a/composer.lock +++ b/composer.lock @@ -2708,16 +2708,16 @@ }, { "name": "symfony/http-client", - "version": "v7.4.7", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "1010624285470eb60e88ed10035102c75b4ea6af" + "reference": "01933e626c3de76bea1e22641e205e78f6a34342" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/1010624285470eb60e88ed10035102c75b4ea6af", - "reference": "1010624285470eb60e88ed10035102c75b4ea6af", + "url": "https://api.github.com/repos/symfony/http-client/zipball/01933e626c3de76bea1e22641e205e78f6a34342", + "reference": "01933e626c3de76bea1e22641e205e78f6a34342", "shasum": "" }, "require": { @@ -2785,7 +2785,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.4.7" + "source": "https://github.com/symfony/http-client/tree/v7.4.8" }, "funding": [ { @@ -2805,7 +2805,7 @@ "type": "tidelift" } ], - "time": "2026-03-05T11:16:58+00:00" + "time": "2026-03-30T12:55:43+00:00" }, { "name": "symfony/http-client-contracts", @@ -4325,16 +4325,16 @@ }, { "name": "utopia-php/http", - "version": "0.34.16", + "version": "0.34.18", "source": { "type": "git", "url": "https://github.com/utopia-php/http.git", - "reference": "2b4021ba3f9d476264ce9fd6703d6c79de9add7f" + "reference": "c8e7e8fc9b9b68aa874e365c83010fefe8ae8ccc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/http/zipball/2b4021ba3f9d476264ce9fd6703d6c79de9add7f", - "reference": "2b4021ba3f9d476264ce9fd6703d6c79de9add7f", + "url": "https://api.github.com/repos/utopia-php/http/zipball/c8e7e8fc9b9b68aa874e365c83010fefe8ae8ccc", + "reference": "c8e7e8fc9b9b68aa874e365c83010fefe8ae8ccc", "shasum": "" }, "require": { @@ -4373,9 +4373,9 @@ ], "support": { "issues": "https://github.com/utopia-php/http/issues", - "source": "https://github.com/utopia-php/http/tree/0.34.16" + "source": "https://github.com/utopia-php/http/tree/0.34.18" }, - "time": "2026-03-20T10:39:07+00:00" + "time": "2026-04-07T08:06:39+00:00" }, { "name": "utopia-php/image", @@ -4696,16 +4696,16 @@ }, { "name": "utopia-php/platform", - "version": "0.12.0", + "version": "0.12.1", "source": { "type": "git", "url": "https://github.com/utopia-php/platform.git", - "reference": "068ee46228f0c3972e6b569f2c86b6c80fe583d8" + "reference": "2a6b88168b3a99d4d7d3b37d927f2cb91da5e0fc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/platform/zipball/068ee46228f0c3972e6b569f2c86b6c80fe583d8", - "reference": "068ee46228f0c3972e6b569f2c86b6c80fe583d8", + "url": "https://api.github.com/repos/utopia-php/platform/zipball/2a6b88168b3a99d4d7d3b37d927f2cb91da5e0fc", + "reference": "2a6b88168b3a99d4d7d3b37d927f2cb91da5e0fc", "shasum": "" }, "require": { @@ -4741,9 +4741,9 @@ ], "support": { "issues": "https://github.com/utopia-php/platform/issues", - "source": "https://github.com/utopia-php/platform/tree/0.12.0" + "source": "https://github.com/utopia-php/platform/tree/0.12.1" }, - "time": "2026-03-31T14:44:23+00:00" + "time": "2026-04-08T04:11:31+00:00" }, { "name": "utopia-php/pools", @@ -5502,16 +5502,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.14.0", + "version": "1.17.6", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "7e7e257b10a8c1384a237e7d8d73452e2108901e" + "reference": "8888a9fd11260d389874424268ecbe0d956eb550" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/7e7e257b10a8c1384a237e7d8d73452e2108901e", - "reference": "7e7e257b10a8c1384a237e7d8d73452e2108901e", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/8888a9fd11260d389874424268ecbe0d956eb550", + "reference": "8888a9fd11260d389874424268ecbe0d956eb550", "shasum": "" }, "require": { @@ -5547,22 +5547,22 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/1.14.0" + "source": "https://github.com/appwrite/sdk-generator/tree/1.17.6" }, - "time": "2026-03-26T12:50:11+00:00" + "time": "2026-04-08T05:37:23+00:00" }, { "name": "brianium/paratest", - "version": "v7.19.2", + "version": "v7.20.0", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9" + "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9", - "reference": "66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/81c80677c9ec0ed4ef16b246167f11dec81a6e3d", + "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d", "shasum": "" }, "require": { @@ -5586,7 +5586,7 @@ "ext-pcntl": "*", "ext-pcov": "*", "ext-posix": "*", - "phpstan/phpstan": "^2.1.40", + "phpstan/phpstan": "^2.1.44", "phpstan/phpstan-deprecation-rules": "^2.0.4", "phpstan/phpstan-phpunit": "^2.0.16", "phpstan/phpstan-strict-rules": "^2.0.10", @@ -5630,7 +5630,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.19.2" + "source": "https://github.com/paratestphp/paratest/tree/v7.20.0" }, "funding": [ { @@ -5642,7 +5642,7 @@ "type": "paypal" } ], - "time": "2026-03-09T14:33:17+00:00" + "time": "2026-03-29T15:46:14+00:00" }, { "name": "czproject/git-php", @@ -6258,11 +6258,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.44", + "version": "2.1.46", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/4a88c083c668b2c364a425c9b3171b2d9ea5d218", - "reference": "4a88c083c668b2c364a425c9b3171b2d9ea5d218", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a193923fc2d6325ef4e741cf3af8c3e8f54dbf25", + "reference": "a193923fc2d6325ef4e741cf3af8c3e8f54dbf25", "shasum": "" }, "require": { @@ -6307,7 +6307,7 @@ "type": "github" } ], - "time": "2026-03-25T17:34:21+00:00" + "time": "2026-04-01T09:25:14+00:00" }, { "name": "phpunit/php-code-coverage", @@ -6657,16 +6657,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.14", + "version": "12.5.17", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0" + "reference": "85b62adab1a340982df64e66daa4a4435eb5723b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/47283cfd98d553edcb1353591f4e255dc1bb61f0", - "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/85b62adab1a340982df64e66daa4a4435eb5723b", + "reference": "85b62adab1a340982df64e66daa4a4435eb5723b", "shasum": "" }, "require": { @@ -6688,7 +6688,7 @@ "sebastian/cli-parser": "^4.2.0", "sebastian/comparator": "^7.1.4", "sebastian/diff": "^7.0.0", - "sebastian/environment": "^8.0.3", + "sebastian/environment": "^8.0.4", "sebastian/exporter": "^7.0.2", "sebastian/global-state": "^8.0.2", "sebastian/object-enumerator": "^7.0.0", @@ -6735,31 +6735,15 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.14" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.17" }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" + "url": "https://phpunit.de/sponsoring.html", + "type": "other" } ], - "time": "2026-02-18T12:38:40+00:00" + "time": "2026-04-08T03:04:19+00:00" }, { "name": "sebastian/cli-parser", @@ -6832,16 +6816,16 @@ }, { "name": "sebastian/comparator", - "version": "7.1.4", + "version": "7.1.5", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6" + "reference": "c284f55811f43d555e51e8e5c166ac40d3e33c63" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/6a7de5df2e094f9a80b40a522391a7e6022df5f6", - "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c284f55811f43d555e51e8e5c166ac40d3e33c63", + "reference": "c284f55811f43d555e51e8e5c166ac40d3e33c63", "shasum": "" }, "require": { @@ -6900,7 +6884,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.4" + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.5" }, "funding": [ { @@ -6920,7 +6904,7 @@ "type": "tidelift" } ], - "time": "2026-01-24T09:28:48+00:00" + "time": "2026-04-08T04:43:00+00:00" }, { "name": "sebastian/complexity", @@ -7744,16 +7728,16 @@ }, { "name": "symfony/console", - "version": "v8.0.7", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a" + "reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a", - "reference": "15ed9008a4ebe2d6a78e4937f74e0c13ef2e618a", + "url": "https://api.github.com/repos/symfony/console/zipball/5b66d385dc58f69652e56f78a4184615e3f2b7f7", + "reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7", "shasum": "" }, "require": { @@ -7810,7 +7794,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.0.7" + "source": "https://github.com/symfony/console/tree/v8.0.8" }, "funding": [ { @@ -7830,7 +7814,7 @@ "type": "tidelift" } ], - "time": "2026-03-06T14:06:22+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/polyfill-ctype", @@ -8164,16 +8148,16 @@ }, { "name": "symfony/process", - "version": "v8.0.5", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674" + "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674", - "reference": "b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674", + "url": "https://api.github.com/repos/symfony/process/zipball/cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", + "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", "shasum": "" }, "require": { @@ -8205,7 +8189,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v8.0.5" + "source": "https://github.com/symfony/process/tree/v8.0.8" }, "funding": [ { @@ -8225,20 +8209,20 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:08:38+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/string", - "version": "v8.0.6", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4" + "reference": "ae9488f874d7603f9d2dfbf120203882b645d963" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/6c9e1108041b5dce21a9a4984b531c4923aa9ec4", - "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4", + "url": "https://api.github.com/repos/symfony/string/zipball/ae9488f874d7603f9d2dfbf120203882b645d963", + "reference": "ae9488f874d7603f9d2dfbf120203882b645d963", "shasum": "" }, "require": { @@ -8295,7 +8279,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.6" + "source": "https://github.com/symfony/string/tree/v8.0.8" }, "funding": [ { @@ -8315,7 +8299,7 @@ "type": "tidelift" } ], - "time": "2026-02-09T10:14:57+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "textalk/websocket", diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php new file mode 100644 index 0000000000..59d2c1db49 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php @@ -0,0 +1,119 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/project/keys') + ->httpAlias('/v1/projects/:projectId/keys') + ->desc('Create project key') + ->groups(['api', 'project']) + ->label('scope', 'keys.write') + ->label('event', 'keys.[keyId].create') + ->label('audits.event', 'project.key.create') + ->label('audits.resource', 'project.key/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'keys', + name: 'createKey', + description: <<param('keyId', '', fn (Database $dbForPlatform) => new CustomId(false, $dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForPlatform']) + ->param('name', null, new Text(128), 'Key name. Max length: 128 chars.') + ->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') + ->param('expire', null, new Nullable(new Datetime()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true) + ->inject('response') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + /** + * @param array|null $scopes + */ + public function action( + string $keyId, + string $name, + ?array $scopes, + ?string $expire, + Response $response, + QueueEvent $queueForEvents, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + ) { + $keyId = ($keyId == 'unique()') ? ID::unique() : $keyId; + + $key = new Document([ + '$id' => $keyId, + '$permissions' => [], + 'resourceInternalId' => $project->getSequence(), + 'resourceId' => $project->getId(), + 'resourceType' => 'projects', + 'name' => $name, + 'scopes' => $scopes ?? [], + 'expire' => $expire, + 'sdks' => [], + 'accessedAt' => null, + 'secret' => API_KEY_STANDARD . '_' . \bin2hex(\random_bytes(128)), + ]); + + try { + $key = $authorization->skip(fn () => $dbForPlatform->createDocument('keys', $key)); + } catch (DuplicateException) { + throw new Exception(Exception::KEY_ALREADY_EXISTS); + } + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('keyId', $key->getId()); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($key, Response::MODEL_KEY); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Delete.php new file mode 100644 index 0000000000..c5da673e22 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Delete.php @@ -0,0 +1,90 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/project/keys/:keyId') + ->httpAlias('/v1/projects/:projectId/keys/:keyId') + ->desc('Delete project key') + ->groups(['api', 'project']) + ->label('scope', 'keys.write') + ->label('event', 'keys.[keyId].delete') + ->label('audits.event', 'project.key.delete') + ->label('audits.resource', 'project.key/{request.keyId}') + ->label('sdk', new Method( + namespace: 'project', + group: 'keys', + name: 'deleteKey', + description: <<param('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key ID.', false, ['dbForPlatform']) + ->inject('response') + ->inject('dbForPlatform') + ->inject('queueForEvents') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $keyId, + Response $response, + Database $dbForPlatform, + Event $queueForEvents, + Document $project, + Authorization $authorization, + ) { + $key = $authorization->skip(fn () => $dbForPlatform->getDocument('keys', $keyId)); + + if ($key->isEmpty() || $key->getAttribute('resourceType', '') !== 'projects' || $key->getAttribute('resourceInternalId', '') !== $project->getSequence()) { + throw new Exception(Exception::KEY_NOT_FOUND); + } + + if (!$authorization->skip(fn () => $dbForPlatform->deleteDocument('keys', $key->getId()))) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove document from DB'); + }; + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('keyId', $key->getId()); + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Get.php new file mode 100644 index 0000000000..e43c669e4f --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Get.php @@ -0,0 +1,74 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/keys/:keyId') + ->httpAlias('/v1/projects/:projectId/keys/:keyId') + ->desc('Get project key') + ->groups(['api', 'project']) + ->label('scope', 'keys.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'keys', + name: 'getKey', + description: <<param('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key ID.', false, ['dbForPlatform']) + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $keyId, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + ) { + $key = $authorization->skip(fn () => $dbForPlatform->getDocument('keys', $keyId)); + + if ($key->isEmpty() || $key->getAttribute('resourceType', '') !== 'projects' || $key->getAttribute('resourceInternalId', '') !== $project->getSequence()) { + throw new Exception(Exception::KEY_NOT_FOUND); + } + + $response->dynamic($key, Response::MODEL_KEY); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php new file mode 100644 index 0000000000..8759faacc1 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php @@ -0,0 +1,111 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/project/keys/:keyId') + ->httpAlias('/v1/projects/:projectId/keys/:keyId') + ->desc('Update project key') + ->groups(['api', 'project']) + ->label('scope', 'keys.write') + ->label('event', 'keys.[keyId].update') + ->label('audits.event', 'project.key.update') + ->label('audits.resource', 'project.key/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'keys', + name: 'updateKey', + description: <<param('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key ID.', false, ['dbForPlatform']) + ->param('name', null, new Text(128), 'Key name. Max length: 128 chars.') + ->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') + ->param('expire', null, new Nullable(new Datetime()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true) + ->inject('response') + ->inject('queueForEvents') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + /** + * @param array|null $scopes + */ + public function action( + string $keyId, + string $name, + ?array $scopes, + ?string $expire, + Response $response, + QueueEvent $queueForEvents, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + ) { + $key = $authorization->skip(fn () => $dbForPlatform->getDocument('keys', $keyId)); + + if ($key->isEmpty() || $key->getAttribute('resourceType', '') !== 'projects' || $key->getAttribute('resourceInternalId', '') !== $project->getSequence()) { + throw new Exception(Exception::KEY_NOT_FOUND); + } + + $updates = new Document([ + 'name' => $name, + 'scopes' => $scopes ?? [], + 'expire' => $expire, + ]); + + try { + $key = $authorization->skip(fn () => $dbForPlatform->updateDocument('keys', $key->getId(), $updates)); + } catch (Duplicate) { + throw new Exception(Exception::KEY_ALREADY_EXISTS); + } + + $authorization->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + + $queueForEvents->setParam('keyId', $key->getId()); + + $response->dynamic($key, Response::MODEL_KEY); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/XList.php new file mode 100644 index 0000000000..d243e6f2c3 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/XList.php @@ -0,0 +1,127 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/keys') + ->httpAlias('/v1/projects/:projectId/keys') + ->desc('List project keys') + ->groups(['api', 'project']) + ->label('scope', 'keys.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'keys', + name: 'listKeys', + description: <<param('queries', [], new Keys(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Keys::ALLOWED_ATTRIBUTES), true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('project') + ->inject('response') + ->inject('dbForPlatform') + ->inject('authorization') + ->callback($this->action(...)); + } + + /** + * @param array $queries + */ + public function action( + array $queries, + bool $includeTotal, + Document $project, + Response $response, + Database $dbForPlatform, + Authorization $authorization, + ) { + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + // Backwards compatibility + if (\count(Query::getByType($queries, [Query::TYPE_LIMIT])) === 0) { + $queries[] = Query::limit(5000); + } + + $queries[] = Query::equal('resourceType', ['projects']); + $queries[] = Query::equal('resourceInternalId', [$project->getSequence()]); + + $cursor = Query::getCursorQueries($queries, false); + $cursor = \reset($cursor); + + if ($cursor !== false) { + $validator = new Cursor(); + if (!$validator->isValid($cursor)) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); + } + + $keyId = $cursor->getValue(); + $cursorDocument = $authorization->skip(fn () => $dbForPlatform->findOne('keys', [ + Query::equal('$id', [$keyId]), + Query::equal('resourceType', ['projects']), + Query::equal('resourceInternalId', [$project->getSequence()]), + ])); + + if ($cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Key '{$keyId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + + try { + $keys = $authorization->skip(fn () => $dbForPlatform->find('keys', $queries)); + $total = $includeTotal ? $authorization->skip(fn () => $dbForPlatform->count('keys', $filterQueries, APP_LIMIT_COUNT)) : 0; + } catch (OrderException $e) { + throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); + } + + $response->dynamic(new Document([ + 'keys' => $keys, + 'total' => $total, + ]), Response::MODEL_KEY_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Android/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Android/Create.php index e33e531017..accc6d5b35 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Android/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Android/Create.php @@ -35,7 +35,7 @@ class Create extends Action ->setHttpPath('/v1/project/platforms/android') ->desc('Create project Android platform') ->groups(['api', 'project']) - ->label('scope', 'project.write') + ->label('scope', 'platforms.write') ->label('event', 'platforms.[platformId].create') ->label('audits.event', 'project.platform.create') ->label('audits.resource', 'project.platform/{response.$id}') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Android/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Android/Update.php index cd12f2da74..3ff958e814 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Android/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Android/Update.php @@ -33,7 +33,7 @@ class Update extends Action ->setHttpPath('/v1/project/platforms/android/:platformId') ->desc('Update project Android platform') ->groups(['api', 'project']) - ->label('scope', 'project.write') + ->label('scope', 'platforms.write') ->label('event', 'platforms.[platformId].update') ->label('audits.event', 'project.platform.update') ->label('audits.resource', 'project.platform/{response.$id}') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Apple/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Apple/Create.php index 4054face8e..0843bf9a0c 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Apple/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Apple/Create.php @@ -35,7 +35,7 @@ class Create extends Action ->setHttpPath('/v1/project/platforms/apple') ->desc('Create project Apple platform') ->groups(['api', 'project']) - ->label('scope', 'project.write') + ->label('scope', 'platforms.write') ->label('event', 'platforms.[platformId].create') ->label('audits.event', 'project.platform.create') ->label('audits.resource', 'project.platform/{response.$id}') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Apple/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Apple/Update.php index 95d67be26c..0295075f19 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Apple/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Apple/Update.php @@ -33,7 +33,7 @@ class Update extends Action ->setHttpPath('/v1/project/platforms/apple/:platformId') ->desc('Update project Apple platform') ->groups(['api', 'project']) - ->label('scope', 'project.write') + ->label('scope', 'platforms.write') ->label('event', 'platforms.[platformId].update') ->label('audits.event', 'project.platform.update') ->label('audits.resource', 'project.platform/{response.$id}') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php index 907046d27e..4b58766751 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Delete.php @@ -33,7 +33,7 @@ class Delete extends Action ->httpAlias('/v1/projects/:projectId/platforms/:platformId') ->desc('Delete project platform') ->groups(['api', 'project']) - ->label('scope', 'project.write') + ->label('scope', 'platforms.write') ->label('event', 'platforms.[platformId].delete') ->label('audits.event', 'project.platform.delete') ->label('audits.resource', 'project.platform/{response.$id}') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Get.php index c5f4b8fc81..de086b13a2 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Get.php @@ -32,7 +32,7 @@ class Get extends Action ->httpAlias('/v1/projects/:projectId/platforms/:platformId') ->desc('Get project platform') ->groups(['api', 'project']) - ->label('scope', 'project.read') + ->label('scope', 'platforms.read') ->label('sdk', new Method( namespace: 'project', group: 'platforms', diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Linux/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Linux/Create.php index ae568740b8..472b41cace 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Linux/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Linux/Create.php @@ -35,7 +35,7 @@ class Create extends Action ->setHttpPath('/v1/project/platforms/linux') ->desc('Create project Linux platform') ->groups(['api', 'project']) - ->label('scope', 'project.write') + ->label('scope', 'platforms.write') ->label('event', 'platforms.[platformId].create') ->label('audits.event', 'project.platform.create') ->label('audits.resource', 'project.platform/{response.$id}') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Linux/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Linux/Update.php index 92674d2276..9c1f715c33 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Linux/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Linux/Update.php @@ -33,7 +33,7 @@ class Update extends Action ->setHttpPath('/v1/project/platforms/linux/:platformId') ->desc('Update project Linux platform') ->groups(['api', 'project']) - ->label('scope', 'project.write') + ->label('scope', 'platforms.write') ->label('event', 'platforms.[platformId].update') ->label('audits.event', 'project.platform.update') ->label('audits.resource', 'project.platform/{response.$id}') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php index f16c0af3fa..6794901c47 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php @@ -43,7 +43,7 @@ class Create extends Action ->httpAlias('/v1/projects/:projectId/platforms') ->desc('Create project web platform') ->groups(['api', 'project']) - ->label('scope', 'project.write') + ->label('scope', 'platforms.write') ->label('event', 'platforms.[platformId].create') ->label('audits.event', 'project.platform.create') ->label('audits.resource', 'project.platform/{response.$id}') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Update.php index 3677466452..1e1f1b5ac1 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Update.php @@ -35,7 +35,7 @@ class Update extends Action ->httpAlias('/v1/projects/:projectId/platforms/:platformId') ->desc('Update project web platform') ->groups(['api', 'project']) - ->label('scope', 'project.write') + ->label('scope', 'platforms.write') ->label('event', 'platforms.[platformId].update') ->label('audits.event', 'project.platform.update') ->label('audits.resource', 'project.platform/{response.$id}') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Windows/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Windows/Create.php index a7e583cadb..58be45d03b 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Windows/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Windows/Create.php @@ -35,7 +35,7 @@ class Create extends Action ->setHttpPath('/v1/project/platforms/windows') ->desc('Create project Windows platform') ->groups(['api', 'project']) - ->label('scope', 'project.write') + ->label('scope', 'platforms.write') ->label('event', 'platforms.[platformId].create') ->label('audits.event', 'project.platform.create') ->label('audits.resource', 'project.platform/{response.$id}') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Windows/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Windows/Update.php index 43d6c65d44..5cfb6ee7ea 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Windows/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Windows/Update.php @@ -33,7 +33,7 @@ class Update extends Action ->setHttpPath('/v1/project/platforms/windows/:platformId') ->desc('Update project Windows platform') ->groups(['api', 'project']) - ->label('scope', 'project.write') + ->label('scope', 'platforms.write') ->label('event', 'platforms.[platformId].update') ->label('audits.event', 'project.platform.update') ->label('audits.resource', 'project.platform/{response.$id}') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/XList.php index 14a67418ee..2953adb4c2 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/XList.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/XList.php @@ -36,7 +36,7 @@ class XList extends Action ->httpAlias('/v1/projects/:projectId/platforms') ->desc('List project platforms') ->groups(['api', 'project']) - ->label('scope', 'project.read') + ->label('scope', 'platforms.read') ->label('sdk', new Method( namespace: 'project', group: 'platforms', diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php index 131cf7245b..2b0ae8feb1 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php @@ -34,7 +34,7 @@ class Delete extends Action ->label('scope', 'project.write') ->label('event', 'variables.[variableId].delete') ->label('audits.event', 'project.variable.delete') - ->label('audits.resource', 'project.variable/{response.$id}') + ->label('audits.resource', 'project.variable/{request.variableId}') ->label('sdk', new Method( namespace: 'project', group: 'variables', diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index 01fe2fcc04..9fd8366097 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -3,6 +3,11 @@ namespace Appwrite\Platform\Modules\Project\Services; use Appwrite\Platform\Modules\Project\Http\Init; +use Appwrite\Platform\Modules\Project\Http\Project\Keys\Create as CreateKey; +use Appwrite\Platform\Modules\Project\Http\Project\Keys\Delete as DeleteKey; +use Appwrite\Platform\Modules\Project\Http\Project\Keys\Get as GetKey; +use Appwrite\Platform\Modules\Project\Http\Project\Keys\Update as UpdateKey; +use Appwrite\Platform\Modules\Project\Http\Project\Keys\XList as ListKeys; use Appwrite\Platform\Modules\Project\Http\Project\Labels\Update as UpdateProjectLabels; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Android\Create as CreateAndroidPlatform; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Android\Update as UpdateAndroidPlatform; @@ -43,6 +48,13 @@ class Http extends Service $this->addAction(DeleteVariable::getName(), new DeleteVariable()); $this->addAction(UpdateVariable::getName(), new UpdateVariable()); + // Keys + $this->addAction(CreateKey::getName(), new CreateKey()); + $this->addAction(ListKeys::getName(), new ListKeys()); + $this->addAction(GetKey::getName(), new GetKey()); + $this->addAction(DeleteKey::getName(), new DeleteKey()); + $this->addAction(UpdateKey::getName(), new UpdateKey()); + // Platforms $this->addAction(DeletePlatform::getName(), new DeletePlatform()); $this->addAction(UpdateWebPlatform::getName(), new UpdateWebPlatform()); diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 2534899f67..43f5c97ba6 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -379,7 +379,11 @@ class Migrations extends Action 'webhooks.read', 'webhooks.write', 'project.read', - 'project.write' + 'project.write', + 'keys.read', + 'keys.write', + 'platforms.read', + 'platforms.write', ] ]); diff --git a/src/Appwrite/Utopia/Request/Filters/V21.php b/src/Appwrite/Utopia/Request/Filters/V21.php index 60ab49255e..357f00cfdc 100644 --- a/src/Appwrite/Utopia/Request/Filters/V21.php +++ b/src/Appwrite/Utopia/Request/Filters/V21.php @@ -71,6 +71,9 @@ class V21 extends Filter case 'webhooks.create': $content = $this->fillWebhookid($content); break; + case 'project.createKey': + $content = $this->fillKeyId($content); + break; case 'project.createVariable': $content = $this->fillVariableId($content); break; @@ -122,6 +125,12 @@ class V21 extends Filter return $content; } + protected function fillKeyId(array $content): array + { + $content['keyId'] = $content['keyId'] ?? 'unique()'; + return $content; + } + protected function fillVariableId(array $content): array { $content['variableId'] = $content['variableId'] ?? 'unique()'; diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index b7037267c5..10641019f0 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -164,7 +164,11 @@ trait ProjectCustom 'webhooks.read', 'webhooks.write', 'project.read', - 'project.write' + 'project.write', + 'keys.read', + 'keys.write', + 'platforms.read', + 'platforms.write', ], ]); diff --git a/tests/e2e/Services/Project/KeysBase.php b/tests/e2e/Services/Project/KeysBase.php new file mode 100644 index 0000000000..fda5ef377f --- /dev/null +++ b/tests/e2e/Services/Project/KeysBase.php @@ -0,0 +1,806 @@ +createKey( + ID::unique(), + 'My API Key', + ['users.read', 'users.write'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $this->assertNotEmpty($key['body']['$id']); + $this->assertSame('My API Key', $key['body']['name']); + $this->assertSame(['users.read', 'users.write'], $key['body']['scopes']); + $this->assertNotEmpty($key['body']['secret']); + $this->assertSame('', $key['body']['expire']); + $this->assertSame('', $key['body']['accessedAt']); + $this->assertSame([], $key['body']['sdks']); + + $dateValidator = new DatetimeValidator(); + $this->assertSame(true, $dateValidator->isValid($key['body']['$createdAt'])); + $this->assertSame(true, $dateValidator->isValid($key['body']['$updatedAt'])); + + // Verify via GET + $get = $this->getKey($key['body']['$id']); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($key['body']['$id'], $get['body']['$id']); + $this->assertSame('My API Key', $get['body']['name']); + $this->assertSame(['users.read', 'users.write'], $get['body']['scopes']); + + // Verify via LIST + $list = $this->listKeys(null, true); + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $list['body']['total']); + $this->assertGreaterThanOrEqual(1, \count($list['body']['keys'])); + + // Cleanup + $this->deleteKey($key['body']['$id']); + } + + public function testCreateKeyWithExpire(): void + { + $expire = '2030-01-01T00:00:00.000+00:00'; + + $key = $this->createKey( + ID::unique(), + 'Expiring Key', + ['users.read'], + $expire, + ); + + $this->assertSame(201, $key['headers']['status-code']); + $this->assertSame($expire, $key['body']['expire']); + + // Verify via GET + $get = $this->getKey($key['body']['$id']); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($expire, $get['body']['expire']); + + // Cleanup + $this->deleteKey($key['body']['$id']); + } + + public function testCreateKeyWithNullScopes(): void + { + $key = $this->createKey( + ID::unique(), + 'Null Scopes Key', + null, + ); + + $this->assertSame(201, $key['headers']['status-code']); + $this->assertSame([], $key['body']['scopes']); + + // Cleanup + $this->deleteKey($key['body']['$id']); + } + + public function testCreateKeyWithoutAuthentication(): void + { + $response = $this->createKey( + ID::unique(), + 'No Auth Key', + ['users.read'], + null, + false + ); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testCreateKeyInvalidId(): void + { + $key = $this->createKey( + '!invalid-id!', + 'Invalid ID Key', + ['users.read'], + ); + + $this->assertSame(400, $key['headers']['status-code']); + } + + public function testCreateKeyMissingName(): void + { + $response = $this->createKey( + ID::unique(), + null, + ['users.read'], + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateKeyInvalidScope(): void + { + $response = $this->createKey( + ID::unique(), + 'Invalid Scope Key', + ['invalid.scope'], + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateKeyDuplicateId(): void + { + $keyId = ID::unique(); + + $key = $this->createKey( + $keyId, + 'Key Dup 1', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + + // Attempt to create with same ID + $duplicate = $this->createKey( + $keyId, + 'Key Dup 2', + ['users.write'], + ); + + $this->assertSame(409, $duplicate['headers']['status-code']); + $this->assertSame('key_already_exists', $duplicate['body']['type']); + + // Cleanup + $this->deleteKey($keyId); + } + + public function testCreateKeyCustomId(): void + { + $customId = 'my-custom-key-id'; + + $key = $this->createKey( + $customId, + 'Custom ID Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $this->assertSame($customId, $key['body']['$id']); + + // Verify via GET + $get = $this->getKey($customId); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($customId, $get['body']['$id']); + + // Cleanup + $this->deleteKey($customId); + } + + // ========================================================================= + // Update key tests + // ========================================================================= + + public function testUpdateKey(): void + { + $key = $this->createKey( + ID::unique(), + 'Original Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + // Update name, scopes, and expire + $expire = '2031-06-15T12:00:00.000+00:00'; + $updated = $this->updateKey($keyId, 'Updated Key', ['users.write', 'databases.read'], $expire); + + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame($keyId, $updated['body']['$id']); + $this->assertSame('Updated Key', $updated['body']['name']); + $this->assertSame(['users.write', 'databases.read'], $updated['body']['scopes']); + $this->assertSame($expire, $updated['body']['expire']); + + // Verify update persisted via GET + $get = $this->getKey($keyId); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('Updated Key', $get['body']['name']); + $this->assertSame(['users.write', 'databases.read'], $get['body']['scopes']); + $this->assertSame($expire, $get['body']['expire']); + + // Cleanup + $this->deleteKey($keyId); + } + + public function testUpdateKeyName(): void + { + $key = $this->createKey( + ID::unique(), + 'Name Before', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + $updated = $this->updateKey($keyId, 'Name After', ['users.read']); + + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame('Name After', $updated['body']['name']); + $this->assertSame(['users.read'], $updated['body']['scopes']); + + // Cleanup + $this->deleteKey($keyId); + } + + public function testUpdateKeyScopes(): void + { + $key = $this->createKey( + ID::unique(), + 'Scopes Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + $updated = $this->updateKey($keyId, 'Scopes Key', ['databases.read', 'databases.write']); + + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame(['databases.read', 'databases.write'], $updated['body']['scopes']); + + // Cleanup + $this->deleteKey($keyId); + } + + public function testUpdateKeySetExpire(): void + { + $key = $this->createKey( + ID::unique(), + 'No Expire Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $this->assertSame('', $key['body']['expire']); + $keyId = $key['body']['$id']; + + $expire = '2032-12-31T23:59:59.000+00:00'; + $updated = $this->updateKey($keyId, 'No Expire Key', ['users.read'], $expire); + + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame($expire, $updated['body']['expire']); + + // Cleanup + $this->deleteKey($keyId); + } + + public function testUpdateKeyRemoveExpire(): void + { + $key = $this->createKey( + ID::unique(), + 'Expire Key', + ['users.read'], + '2030-01-01T00:00:00.000+00:00', + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + // Remove expire by setting to null + $updated = $this->updateKey($keyId, 'Expire Key', ['users.read'], null); + + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame('', $updated['body']['expire']); + + // Cleanup + $this->deleteKey($keyId); + } + + public function testUpdateKeyWithoutAuthentication(): void + { + $key = $this->createKey( + ID::unique(), + 'Auth Update Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + // Attempt update without authentication + $response = $this->updateKey($keyId, 'Updated Name', ['users.read'], null, false); + + $this->assertSame(401, $response['headers']['status-code']); + + // Cleanup + $this->deleteKey($keyId); + } + + public function testUpdateKeyNotFound(): void + { + $updated = $this->updateKey('non-existent-id', 'New Name', ['users.read']); + + $this->assertSame(404, $updated['headers']['status-code']); + $this->assertSame('key_not_found', $updated['body']['type']); + } + + public function testUpdateKeyInvalidScope(): void + { + $key = $this->createKey( + ID::unique(), + 'Invalid Scope Update', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + $updated = $this->updateKey($keyId, 'Invalid Scope Update', ['invalid.scope']); + + $this->assertSame(400, $updated['headers']['status-code']); + + // Cleanup + $this->deleteKey($keyId); + } + + // ========================================================================= + // Get key tests + // ========================================================================= + + public function testGetKey(): void + { + $key = $this->createKey( + ID::unique(), + 'Get Test Key', + ['users.read', 'databases.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + $get = $this->getKey($keyId); + + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($keyId, $get['body']['$id']); + $this->assertSame('Get Test Key', $get['body']['name']); + $this->assertSame(['users.read', 'databases.read'], $get['body']['scopes']); + $this->assertNotEmpty($get['body']['secret']); + $this->assertSame('', $get['body']['expire']); + $this->assertSame('', $get['body']['accessedAt']); + $this->assertSame([], $get['body']['sdks']); + + $dateValidator = new DatetimeValidator(); + $this->assertSame(true, $dateValidator->isValid($get['body']['$createdAt'])); + $this->assertSame(true, $dateValidator->isValid($get['body']['$updatedAt'])); + + // Cleanup + $this->deleteKey($keyId); + } + + public function testGetKeyNotFound(): void + { + $get = $this->getKey('non-existent-id'); + + $this->assertSame(404, $get['headers']['status-code']); + $this->assertSame('key_not_found', $get['body']['type']); + } + + public function testGetKeyWithoutAuthentication(): void + { + $key = $this->createKey( + ID::unique(), + 'Auth Get Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + // Attempt GET without authentication + $response = $this->getKey($keyId, false); + + $this->assertSame(401, $response['headers']['status-code']); + + // Cleanup + $this->deleteKey($keyId); + } + + // ========================================================================= + // List keys tests + // ========================================================================= + + public function testListKeys(): void + { + // Create multiple keys + $key1 = $this->createKey( + ID::unique(), + 'List Key Alpha', + ['users.read'], + ); + $this->assertSame(201, $key1['headers']['status-code']); + + $key2 = $this->createKey( + ID::unique(), + 'List Key Beta', + ['databases.read'], + ); + $this->assertSame(201, $key2['headers']['status-code']); + + $key3 = $this->createKey( + ID::unique(), + 'List Key Gamma', + ['users.write'], + ); + $this->assertSame(201, $key3['headers']['status-code']); + + // List all + $list = $this->listKeys(null, true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(3, $list['body']['total']); + $this->assertGreaterThanOrEqual(3, \count($list['body']['keys'])); + $this->assertIsArray($list['body']['keys']); + + // Verify structure of returned keys + foreach ($list['body']['keys'] as $key) { + $this->assertArrayHasKey('$id', $key); + $this->assertArrayHasKey('$createdAt', $key); + $this->assertArrayHasKey('$updatedAt', $key); + $this->assertArrayHasKey('name', $key); + $this->assertArrayHasKey('scopes', $key); + $this->assertArrayHasKey('secret', $key); + $this->assertArrayHasKey('expire', $key); + $this->assertArrayHasKey('accessedAt', $key); + $this->assertArrayHasKey('sdks', $key); + } + + // Cleanup + $this->deleteKey($key1['body']['$id']); + $this->deleteKey($key2['body']['$id']); + $this->deleteKey($key3['body']['$id']); + } + + public function testListKeysWithLimit(): void + { + $key1 = $this->createKey( + ID::unique(), + 'Limit Key 1', + ['users.read'], + ); + $this->assertSame(201, $key1['headers']['status-code']); + + $key2 = $this->createKey( + ID::unique(), + 'Limit Key 2', + ['users.write'], + ); + $this->assertSame(201, $key2['headers']['status-code']); + + // List with limit 1 + $list = $this->listKeys([ + Query::limit(1)->toString(), + ], true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertCount(1, $list['body']['keys']); + $this->assertGreaterThanOrEqual(2, $list['body']['total']); + + // Cleanup + $this->deleteKey($key1['body']['$id']); + $this->deleteKey($key2['body']['$id']); + } + + public function testListKeysWithoutTotal(): void + { + $key = $this->createKey( + ID::unique(), + 'No Total Key', + ['users.read'], + ); + $this->assertSame(201, $key['headers']['status-code']); + + // List with total=false + $list = $this->listKeys(null, false); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertSame(0, $list['body']['total']); + $this->assertGreaterThanOrEqual(1, \count($list['body']['keys'])); + + // Cleanup + $this->deleteKey($key['body']['$id']); + } + + public function testListKeysCursorPagination(): void + { + $key1 = $this->createKey( + ID::unique(), + 'Cursor Key 1', + ['users.read'], + ); + $this->assertSame(201, $key1['headers']['status-code']); + + $key2 = $this->createKey( + ID::unique(), + 'Cursor Key 2', + ['users.write'], + ); + $this->assertSame(201, $key2['headers']['status-code']); + + // Get first page with limit 1 + $page1 = $this->listKeys([ + Query::limit(1)->toString(), + ], true); + + $this->assertSame(200, $page1['headers']['status-code']); + $this->assertCount(1, $page1['body']['keys']); + $cursorId = $page1['body']['keys'][0]['$id']; + + // Get next page using cursor + $page2 = $this->listKeys([ + Query::limit(1)->toString(), + Query::cursorAfter(new Document(['$id' => $cursorId]))->toString(), + ], true); + + $this->assertSame(200, $page2['headers']['status-code']); + $this->assertCount(1, $page2['body']['keys']); + $this->assertNotEquals($cursorId, $page2['body']['keys'][0]['$id']); + + // Cleanup + $this->deleteKey($key1['body']['$id']); + $this->deleteKey($key2['body']['$id']); + } + + public function testListKeysWithoutAuthentication(): void + { + $response = $this->listKeys(null, null, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testListKeysInvalidCursor(): void + { + $list = $this->listKeys([ + Query::cursorAfter(new Document(['$id' => 'non-existent-id']))->toString(), + ], true); + + $this->assertSame(400, $list['headers']['status-code']); + } + + // ========================================================================= + // Delete key tests + // ========================================================================= + + public function testDeleteKey(): void + { + $key = $this->createKey( + ID::unique(), + 'Delete Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + // Verify it exists + $get = $this->getKey($keyId); + $this->assertSame(200, $get['headers']['status-code']); + + // Delete + $delete = $this->deleteKey($keyId); + $this->assertSame(204, $delete['headers']['status-code']); + $this->assertEmpty($delete['body']); + + // Verify it no longer exists + $get = $this->getKey($keyId); + $this->assertSame(404, $get['headers']['status-code']); + $this->assertSame('key_not_found', $get['body']['type']); + } + + public function testDeleteKeyNotFound(): void + { + $delete = $this->deleteKey('non-existent-id'); + + $this->assertSame(404, $delete['headers']['status-code']); + $this->assertSame('key_not_found', $delete['body']['type']); + } + + public function testDeleteKeyWithoutAuthentication(): void + { + $key = $this->createKey( + ID::unique(), + 'Delete Auth Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + // Attempt DELETE without authentication + $response = $this->deleteKey($keyId, false); + + $this->assertSame(401, $response['headers']['status-code']); + + // Verify it still exists + $get = $this->getKey($keyId); + $this->assertSame(200, $get['headers']['status-code']); + + // Cleanup + $this->deleteKey($keyId); + } + + public function testDeleteKeyRemovedFromList(): void + { + $key = $this->createKey( + ID::unique(), + 'Delete List Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + // Get list count before delete + $listBefore = $this->listKeys(null, true); + $this->assertSame(200, $listBefore['headers']['status-code']); + $countBefore = $listBefore['body']['total']; + + // Delete + $delete = $this->deleteKey($keyId); + $this->assertSame(204, $delete['headers']['status-code']); + + // Get list count after delete + $listAfter = $this->listKeys(null, true); + $this->assertSame(200, $listAfter['headers']['status-code']); + $this->assertSame($countBefore - 1, $listAfter['body']['total']); + + // Verify the deleted key is not in the list + $ids = \array_column($listAfter['body']['keys'], '$id'); + $this->assertNotContains($keyId, $ids); + } + + public function testDeleteKeyDoubleDelete(): void + { + $key = $this->createKey( + ID::unique(), + 'Double Delete Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + // First delete succeeds + $delete = $this->deleteKey($keyId); + $this->assertSame(204, $delete['headers']['status-code']); + + // Second delete returns 404 + $delete = $this->deleteKey($keyId); + $this->assertSame(404, $delete['headers']['status-code']); + $this->assertSame('key_not_found', $delete['body']['type']); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + /** + * @param array|null $scopes + */ + protected function createKey(string $keyId, ?string $name, ?array $scopes = null, ?string $expire = null, bool $authenticated = true): mixed + { + $params = [ + 'keyId' => $keyId, + 'scopes' => $scopes, + ]; + + if ($name !== null) { + $params['name'] = $name; + } + + if ($expire !== null) { + $params['expire'] = $expire; + } + + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_POST, '/project/keys', $headers, $params); + } + + /** + * @param array|null $scopes + */ + protected function updateKey(string $keyId, ?string $name = null, ?array $scopes = null, ?string $expire = null, bool $authenticated = true): mixed + { + $params = []; + + if ($name !== null) { + $params['name'] = $name; + } + + if ($scopes !== null) { + $params['scopes'] = $scopes; + } + + if ($expire !== null) { + $params['expire'] = $expire; + } + + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_PUT, '/project/keys/' . $keyId, $headers, $params); + } + + protected function getKey(string $keyId, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_GET, '/project/keys/' . $keyId, $headers); + } + + /** + * @param array|null $queries + */ + protected function listKeys(?array $queries, ?bool $total, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_GET, '/project/keys', $headers, [ + 'queries' => $queries, + 'total' => $total, + ]); + } + + protected function deleteKey(string $keyId, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_DELETE, '/project/keys/' . $keyId, $headers); + } +} diff --git a/tests/e2e/Services/Project/KeysConsoleClientTest.php b/tests/e2e/Services/Project/KeysConsoleClientTest.php new file mode 100644 index 0000000000..ad6ed28b77 --- /dev/null +++ b/tests/e2e/Services/Project/KeysConsoleClientTest.php @@ -0,0 +1,14 @@ +client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'name' => 'Key Test', 'scopes' => ['teams.read', 'teams.write'], diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index d6fa0d4f5c..e0f94b64cc 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -3161,6 +3161,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'name' => 'Key Custom', 'scopes' => ['teams.read', 'teams.write'], @@ -3246,6 +3247,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'name' => 'Key Test 2', 'scopes' => ['users.read'], @@ -3621,6 +3623,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.8.0', ], $this->getHeaders()), [ 'name' => 'Key For Deletion', 'scopes' => ['teams.read', 'teams.write'],