From 59f178d634675e4c1e516228e028cba2ba6b8d0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 18 Dec 2025 13:37:50 +0100 Subject: [PATCH 01/39] Improve PHP types for extensability --- src/Appwrite/Auth/Key.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Appwrite/Auth/Key.php b/src/Appwrite/Auth/Key.php index b1f3836fb6..1adfa2be2d 100644 --- a/src/Appwrite/Auth/Key.php +++ b/src/Appwrite/Auth/Key.php @@ -95,15 +95,12 @@ class Key * Decode the given secret key into a Key object, containing the project ID, type, role, scopes, and name. * Can be a stored API key or a dynamic key (JWT). * - * @param Document $project - * @param string $key - * @return Key * @throws Exception */ public static function decode( Document $project, string $key - ): Key { + ): static { if (\str_contains($key, '_')) { [$type, $secret] = \explode('_', $key, 2); } else { From 6f16b56f31b7f0670dd1f1c22ab8cbc3265e48fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 18 Dec 2025 15:55:11 +0100 Subject: [PATCH 02/39] Allow Key extensions --- src/Appwrite/Auth/Key.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Appwrite/Auth/Key.php b/src/Appwrite/Auth/Key.php index 1adfa2be2d..df906ccd15 100644 --- a/src/Appwrite/Auth/Key.php +++ b/src/Appwrite/Auth/Key.php @@ -11,6 +11,9 @@ use Utopia\Database\DateTime; use Utopia\Database\Document; use Utopia\System\System; +/** + * @template T of Key + */ class Key { public function __construct( @@ -96,6 +99,7 @@ class Key * Can be a stored API key or a dynamic key (JWT). * * @throws Exception + * @return T */ public static function decode( Document $project, From 1c3f778da9d9fb4a9e2f6de8aa7ac0a3a048bf5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 19 Dec 2025 12:26:15 +0100 Subject: [PATCH 03/39] PR review fix --- src/Appwrite/Auth/Key.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Auth/Key.php b/src/Appwrite/Auth/Key.php index df906ccd15..44f546eaa4 100644 --- a/src/Appwrite/Auth/Key.php +++ b/src/Appwrite/Auth/Key.php @@ -104,7 +104,7 @@ class Key public static function decode( Document $project, string $key - ): static { + ) { if (\str_contains($key, '_')) { [$type, $secret] = \explode('_', $key, 2); } else { From 0a3877a9006a3033cb39040ec2ba831978e5f8f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 19 Dec 2025 13:09:34 +0100 Subject: [PATCH 04/39] New DB schema --- app/config/collections/platform.php | 37 +++++++++++++++++++---------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/app/config/collections/platform.php b/app/config/collections/platform.php index d44d9b725c..eb4184b72a 100644 --- a/app/config/collections/platform.php +++ b/app/config/collections/platform.php @@ -622,27 +622,38 @@ return [ 'name' => 'keys', 'attributes' => [ [ - '$id' => ID::custom('projectInternalId'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('projectId'), + '$id' => ID::custom('resourceId'), 'type' => Database::VAR_STRING, 'format' => '', 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => false, - 'default' => 0, + 'default' => null, 'array' => false, 'filters' => [], ], + [ + '$id' => ID::custom('resourceInternalId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('resourceType'), // project, team, user + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [] + ], [ '$id' => ID::custom('name'), 'type' => Database::VAR_STRING, From 69d5ce0f550bc4d5411e3ca7b6d010abd45769a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 19 Dec 2025 13:40:32 +0100 Subject: [PATCH 05/39] Switch over to resource-based key DB structure --- app/config/collections/platform.php | 2 +- app/controllers/api/projects.php | 17 +++++++++++------ app/controllers/mock.php | 5 +++-- app/init/database/filters.php | 3 ++- src/Appwrite/Platform/Workers/Deletes.php | 3 ++- .../Platform/Workers/StatsResources.php | 3 ++- 6 files changed, 21 insertions(+), 12 deletions(-) diff --git a/app/config/collections/platform.php b/app/config/collections/platform.php index eb4184b72a..0ad1b3fbc0 100644 --- a/app/config/collections/platform.php +++ b/app/config/collections/platform.php @@ -653,7 +653,7 @@ return [ 'default' => null, 'array' => false, 'filters' => [] - ], + ], [ '$id' => ID::custom('name'), 'type' => Database::VAR_STRING, diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index c4d703d744..45a63e4966 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -1497,8 +1497,9 @@ App::post('/v1/projects/:projectId/keys') Permission::update(Role::any()), Permission::delete(Role::any()), ], - 'projectInternalId' => $project->getSequence(), - 'projectId' => $project->getId(), + 'resourceInternalId' => $project->getSequence(), + 'resourceId' => $project->getId(), + 'resourceType' => 'projects', 'name' => $name, 'scopes' => $scopes, 'expire' => $expire, @@ -1546,7 +1547,8 @@ App::get('/v1/projects/:projectId/keys') } $keys = $dbForPlatform->find('keys', [ - Query::equal('projectInternalId', [$project->getSequence()]), + Query::equal('resourceInternalId', [$project->getSequence()]), + Query::equal('resourceType', ['projects']), Query::limit(5000), ]); @@ -1587,7 +1589,8 @@ App::get('/v1/projects/:projectId/keys/:keyId') $key = $dbForPlatform->findOne('keys', [ Query::equal('$id', [$keyId]), - Query::equal('projectInternalId', [$project->getSequence()]), + Query::equal('resourceInternalId', [$project->getSequence()]), + Query::equal('resourceType', ['projects']), ]); if ($key->isEmpty()) { @@ -1631,7 +1634,8 @@ App::put('/v1/projects/:projectId/keys/:keyId') $key = $dbForPlatform->findOne('keys', [ Query::equal('$id', [$keyId]), - Query::equal('projectInternalId', [$project->getSequence()]), + Query::equal('resourceInternalId', [$project->getSequence()]), + Query::equal('resourceType', ['projects']), ]); if ($key->isEmpty()) { @@ -1682,7 +1686,8 @@ App::delete('/v1/projects/:projectId/keys/:keyId') $key = $dbForPlatform->findOne('keys', [ Query::equal('$id', [$keyId]), - Query::equal('projectInternalId', [$project->getSequence()]), + Query::equal('resourceInternalId', [$project->getSequence()]), + Query::equal('resourceType', ['projects']), ]); if ($key->isEmpty()) { diff --git a/app/controllers/mock.php b/app/controllers/mock.php index 6f092a5d19..2c0ef443ee 100644 --- a/app/controllers/mock.php +++ b/app/controllers/mock.php @@ -200,8 +200,9 @@ App::post('/v1/mock/api-key-unprefixed') Permission::update(Role::any()), Permission::delete(Role::any()), ], - 'projectInternalId' => $project->getSequence(), - 'projectId' => $project->getId(), + 'resourceType' => 'projects', + 'resourceInternalId' => $project->getSequence(), + 'resourceId' => $project->getId(), 'name' => 'Outdated key', 'scopes' => $scopes, 'expire' => null, diff --git a/app/init/database/filters.php b/app/init/database/filters.php index 2bff778017..590c78be42 100644 --- a/app/init/database/filters.php +++ b/app/init/database/filters.php @@ -135,7 +135,8 @@ Database::addFilter( function (mixed $value, Document $document, Database $database) { return $database->getAuthorization()->skip(fn () => $database ->find('keys', [ - Query::equal('projectInternalId', [$document->getSequence()]), + Query::equal('resourceInternalId', [$document->getSequence()]), + Query::equal('resourceType', ['projects']), Query::limit(APP_LIMIT_SUBQUERY), ])); } diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index 247044f4c3..072dfa9bf9 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -562,7 +562,8 @@ class Deletes extends Action // Delete Keys $this->deleteByGroup('keys', [ - Query::equal('projectInternalId', [$projectInternalId]), + Query::equal('resourceInternalId', [$projectInternalId]), + Query::equal('resourceType', ['projects']), Query::orderAsc() ], $dbForPlatform); diff --git a/src/Appwrite/Platform/Workers/StatsResources.php b/src/Appwrite/Platform/Workers/StatsResources.php index 1ef348091a..118f83c031 100644 --- a/src/Appwrite/Platform/Workers/StatsResources.php +++ b/src/Appwrite/Platform/Workers/StatsResources.php @@ -111,7 +111,8 @@ class StatsResources extends Action Query::equal('projectInternalId', [$project->getSequence()]) ]); $keys = $dbForPlatform->count('keys', [ - Query::equal('projectInternalId', [$project->getSequence()]) + Query::equal('resourceInternalId', [$project->getSequence()]), + Query::equal('resourceType', ['projects']), ]); $domains = $dbForPlatform->count('rules', [ From 859d146e852cb5bcc2012a059ee8006229d9a0fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 19 Dec 2025 13:41:18 +0100 Subject: [PATCH 06/39] Apply suggestions from code review --- app/config/collections/platform.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/config/collections/platform.php b/app/config/collections/platform.php index 0ad1b3fbc0..5538f59133 100644 --- a/app/config/collections/platform.php +++ b/app/config/collections/platform.php @@ -644,7 +644,7 @@ return [ 'filters' => [], ], [ - '$id' => ID::custom('resourceType'), // project, team, user + '$id' => ID::custom('resourceType'), // projects, teams, users 'type' => Database::VAR_STRING, 'format' => '', 'size' => Database::LENGTH_KEY, From c69382f29f6e8fa52ee53ea03657b6879754817c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 19 Dec 2025 16:11:43 +0100 Subject: [PATCH 07/39] Fix invalid index --- app/config/collections/platform.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/config/collections/platform.php b/app/config/collections/platform.php index 5538f59133..16eafc9d4a 100644 --- a/app/config/collections/platform.php +++ b/app/config/collections/platform.php @@ -723,9 +723,9 @@ return [ ], 'indexes' => [ [ - '$id' => ID::custom('_key_project'), + '$id' => ID::custom('_key_resource'), 'type' => Database::INDEX_KEY, - 'attributes' => ['projectInternalId'], + 'attributes' => ['resourceType', 'resourceInternalId'], 'lengths' => [Database::LENGTH_KEY], 'orders' => [Database::ORDER_ASC], ], From ca43281fa9df2ec551da2e4d467bafcce1fdd337 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sat, 20 Dec 2025 09:20:39 +0100 Subject: [PATCH 08/39] Simplify PR --- src/Appwrite/Auth/Key.php | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/Appwrite/Auth/Key.php b/src/Appwrite/Auth/Key.php index 44f546eaa4..b23f2cc816 100644 --- a/src/Appwrite/Auth/Key.php +++ b/src/Appwrite/Auth/Key.php @@ -11,9 +11,6 @@ use Utopia\Database\DateTime; use Utopia\Database\Document; use Utopia\System\System; -/** - * @template T of Key - */ class Key { public function __construct( @@ -99,12 +96,11 @@ class Key * Can be a stored API key or a dynamic key (JWT). * * @throws Exception - * @return T */ public static function decode( Document $project, string $key - ) { + ): Key { if (\str_contains($key, '_')) { [$type, $secret] = \explode('_', $key, 2); } else { From 465912822fdab0fdc21c4801930b9d878b064b50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 22 Dec 2025 11:32:45 +0100 Subject: [PATCH 09/39] Mark reused key response public --- src/Appwrite/Utopia/Response/Model/Key.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Utopia/Response/Model/Key.php b/src/Appwrite/Utopia/Response/Model/Key.php index 1adab4417b..38aa0748df 100644 --- a/src/Appwrite/Utopia/Response/Model/Key.php +++ b/src/Appwrite/Utopia/Response/Model/Key.php @@ -10,7 +10,7 @@ class Key extends Model /** * @var bool */ - protected bool $public = false; + protected bool $public = true; public function __construct() { From 09d71e73afbb86b4d3ab00f7779c3e85fd88d01c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 22 Dec 2025 11:37:38 +0100 Subject: [PATCH 10/39] keys list to be public as its reused for more types of keys --- src/Appwrite/Utopia/Response.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 33351bea14..68c2cb14c8 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -466,7 +466,7 @@ class Response extends SwooleResponse ->setModel(new BaseList('Executions List', self::MODEL_EXECUTION_LIST, 'executions', self::MODEL_EXECUTION)) ->setModel(new BaseList('Projects List', self::MODEL_PROJECT_LIST, 'projects', self::MODEL_PROJECT, true, false)) ->setModel(new BaseList('Webhooks List', self::MODEL_WEBHOOK_LIST, 'webhooks', self::MODEL_WEBHOOK, true, false)) - ->setModel(new BaseList('API Keys List', self::MODEL_KEY_LIST, 'keys', self::MODEL_KEY, true, false)) + ->setModel(new BaseList('API Keys List', self::MODEL_KEY_LIST, 'keys', self::MODEL_KEY, true, true)) // Public because reused for more key types ->setModel(new BaseList('Dev Keys List', self::MODEL_DEV_KEY_LIST, 'devKeys', self::MODEL_DEV_KEY, true, false)) ->setModel(new BaseList('Auth Providers List', self::MODEL_AUTH_PROVIDER_LIST, 'platforms', self::MODEL_AUTH_PROVIDER, true, false)) ->setModel(new BaseList('Platforms List', self::MODEL_PLATFORM_LIST, 'platforms', self::MODEL_PLATFORM, true, false)) From bce3ce85d514c759fb9ae696b1b178be09c5b93a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 23 Dec 2025 11:59:38 +0100 Subject: [PATCH 11/39] account key support --- app/init/resources.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/init/resources.php b/app/init/resources.php index 30717141f6..6d2ce06709 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -430,6 +430,25 @@ App::setResource('user', function (string $mode, Document $project, Document $co } } } + + // Account based on account API key + $accountKey = $request->getHeader('x-appwrite-key', ''); + $accountKeyId = $request->getHeader('x-appwrite-user', ''); + if (!empty($accountKeyId) && !empty($accountKey)) { + $accountKeyUser = Authorization::skip(fn () => $dbForPlatform->getDocument('users', $accountKeyId)); + if (!$accountKeyUser->isEmpty()) { + $key = $accountKeyUser->find( + key: 'secret', + find: $accountKey, + subject: 'keys' + ); + + if (!empty($key)) { + $user = $accountKeyUser; + } + } + } + $dbForProject->setMetadata('user', $user->getId()); $dbForPlatform->setMetadata('user', $user->getId()); From 6f7cca7d6860b8f6349f642bf8615a70d005fe20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 23 Dec 2025 12:06:30 +0100 Subject: [PATCH 12/39] Allow key header for account keys --- app/controllers/shared/api.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 83b56f626a..c4ca334921 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -301,10 +301,6 @@ App::init() // Step 5: API Key Authentication if (!empty($apiKey)) { - // Verify no user session exists simultaneously - if (!$user->isEmpty()) { - throw new Exception(Exception::USER_API_KEY_AND_SESSION_SET); - } // Check if key is expired if ($apiKey->isExpired()) { throw new Exception(Exception::PROJECT_KEY_EXPIRED); From b0bd9e5b78ded2f0cac989bb7f7ef572265406dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 23 Dec 2025 12:09:07 +0100 Subject: [PATCH 13/39] Fix security requirement --- app/init/resources.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/init/resources.php b/app/init/resources.php index 6d2ce06709..24287ca243 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -433,9 +433,9 @@ App::setResource('user', function (string $mode, Document $project, Document $co // Account based on account API key $accountKey = $request->getHeader('x-appwrite-key', ''); - $accountKeyId = $request->getHeader('x-appwrite-user', ''); - if (!empty($accountKeyId) && !empty($accountKey)) { - $accountKeyUser = Authorization::skip(fn () => $dbForPlatform->getDocument('users', $accountKeyId)); + $accountKeyUserId = $request->getHeader('x-appwrite-user', ''); + if (!empty($accountKeyUserId) && !empty($accountKey)) { + $accountKeyUser = Authorization::skip(fn () => $dbForPlatform->getDocument('users', $accountKeyUserId)); if (!$accountKeyUser->isEmpty()) { $key = $accountKeyUser->find( key: 'secret', From cca49f8f6a2b2a06ab1afdfe00d09ad36ad79b27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 23 Dec 2025 12:11:01 +0100 Subject: [PATCH 14/39] Improve docs --- app/init/resources.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/init/resources.php b/app/init/resources.php index 24287ca243..c59ef5553a 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -333,6 +333,7 @@ App::setResource('user', function (string $mode, Document $project, Document $co * 5. Regardless of the results from steps 1-4, attempts to fetch the JWT token. * 6. If the JWT user has a valid session ID, updates the user variable with the user from `projectDB`, * overwriting the previous value. + * 7. If account key is passed, use user of the account key as long as user ID header matches too */ Authorization::setDefaultStatus(true); From 6e47fb6c7021e775ea468b0dcd42da5d10c91e8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 23 Dec 2025 13:06:19 +0100 Subject: [PATCH 15/39] Implement auth for organization and account keys --- app/config/scopes/account.php | 13 +++ app/config/scopes/organization.php | 42 +++++++++ app/config/{scopes.php => scopes/project.php} | 0 app/controllers/api/projects.php | 6 +- app/controllers/mock.php | 2 +- app/init/configs.php | 4 +- app/init/constants.php | 2 + app/init/resources.php | 6 +- src/Appwrite/Auth/Key.php | 86 +++++++++++++++++++ .../Functions/Http/Functions/Create.php | 2 +- .../Functions/Http/Functions/Update.php | 2 +- src/Appwrite/Platform/Tasks/Screenshot.php | 2 +- 12 files changed, 156 insertions(+), 11 deletions(-) create mode 100644 app/config/scopes/account.php create mode 100644 app/config/scopes/organization.php rename app/config/{scopes.php => scopes/project.php} (100%) diff --git a/app/config/scopes/account.php b/app/config/scopes/account.php new file mode 100644 index 0000000000..f11e49ca76 --- /dev/null +++ b/app/config/scopes/account.php @@ -0,0 +1,13 @@ + [ + "description" => 'Access to manage account, it\'s organizations, sessions, tokens, and billing.', + ],"teams.read" => [ + "description" => 'Access to read account\'s organizations.', + ],"teams.write" => [ + "description" => 'Access to create, update and delete account\'s organizations and it\'s memberships.', + ], +]; diff --git a/app/config/scopes/organization.php b/app/config/scopes/organization.php new file mode 100644 index 0000000000..ca4160881d --- /dev/null +++ b/app/config/scopes/organization.php @@ -0,0 +1,42 @@ + [ + "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', + ], + "projects.write" => [ + "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', + ], + "devKeys.write" => [ + "description" => + "Access to create, update, and delete project\'s development keys", + ], + "webhooks.read" => [ + "description" => + "Access to read project\'s webhooks", + ], + "webhooks.write" => [ + "description" => + "Access to create, update, and delete project\'s webhooks", + ], +]; diff --git a/app/config/scopes.php b/app/config/scopes/project.php similarity index 100% rename from app/config/scopes.php rename to app/config/scopes/project.php diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 45a63e4966..c23ac05a6e 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -1478,7 +1478,7 @@ App::post('/v1/projects/:projectId/keys') )) ->param('projectId', '', new UID(), 'Project unique ID.') ->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('scopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') + ->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') @@ -1620,7 +1620,7 @@ App::put('/v1/projects/:projectId/keys/:keyId') ->param('projectId', '', new UID(), 'Project unique ID.') ->param('keyId', '', new UID(), 'Key unique ID.') ->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('scopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' events are allowed.') + ->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') @@ -1721,7 +1721,7 @@ App::post('/v1/projects/:projectId/jwts') ] )) ->param('projectId', '', new UID(), 'Project unique ID.') - ->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('scopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of scopes allowed for JWT key. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') + ->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of scopes allowed for JWT key. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') ->param('duration', 900, new Range(0, 3600), 'Time in seconds before JWT expires. Default duration is 900 seconds, and maximum is 3600 seconds.', true) ->inject('response') ->inject('dbForPlatform') diff --git a/app/controllers/mock.php b/app/controllers/mock.php index 2c0ef443ee..29f35e9c3c 100644 --- a/app/controllers/mock.php +++ b/app/controllers/mock.php @@ -191,7 +191,7 @@ App::post('/v1/mock/api-key-unprefixed') throw new Exception(Exception::PROJECT_NOT_FOUND); } - $scopes = array_keys(Config::getParam('scopes')); + $scopes = array_keys(Config::getParam('projectScopes')); $key = new Document([ '$id' => ID::unique(), diff --git a/app/init/configs.php b/app/init/configs.php index 19be7755dd..d5748707cf 100644 --- a/app/init/configs.php +++ b/app/init/configs.php @@ -22,7 +22,9 @@ Config::load('collections', __DIR__ . '/../config/collections.php', $configAdapt Config::load('frameworks', __DIR__ . '/../config/frameworks.php', $configAdapter); Config::load('usage', __DIR__ . '/../config/usage.php', $configAdapter); Config::load('roles', __DIR__ . '/../config/roles.php', $configAdapter); // User roles and scopes -Config::load('scopes', __DIR__ . '/../config/scopes.php', $configAdapter); // User roles and scopes +Config::load('projectScopes', __DIR__ . '/../config/scopes/project.php', $configAdapter); +Config::load('organizationScopes', __DIR__ . '/../config/scopes/organization.php', $configAdapter); +Config::load('accountScopes', __DIR__ . '/../config/scopes/account.php', $configAdapter); Config::load('services', __DIR__ . '/../config/services.php', $configAdapter); // List of services Config::load('variables', __DIR__ . '/../config/variables.php', $configAdapter); // List of env variables Config::load('regions', __DIR__ . '/../config/regions.php', $configAdapter); // List of available regions diff --git a/app/init/constants.php b/app/init/constants.php index 78b8e3a5ae..3a8eb72e62 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -243,6 +243,8 @@ const MESSAGE_TYPE_PUSH = 'push'; // API key types const API_KEY_STANDARD = 'standard'; const API_KEY_DYNAMIC = 'dynamic'; +const API_KEY_ORGANIZATION = 'organization'; +const API_KEY_ACCOUNT = 'account'; // Usage metrics const METRIC_TEAMS = 'teams'; const METRIC_USERS = 'users'; diff --git a/app/init/resources.php b/app/init/resources.php index c59ef5553a..1db546e0d0 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -1072,15 +1072,15 @@ App::setResource('previewHostname', function (Request $request, ?Key $apiKey) { return ''; }, ['request', 'apiKey']); -App::setResource('apiKey', function (Request $request, Document $project): ?Key { +App::setResource('apiKey', function (Request $request, Document $project, Document $team, Document $user): ?Key { $key = $request->getHeader('x-appwrite-key'); if (empty($key)) { return null; } - return Key::decode($project, $key); -}, ['request', 'project']); + return Key::decode($project, $team, $user, $key); +}, ['request', 'project', 'team', 'user']); App::setResource('executor', fn () => new Executor()); diff --git a/src/Appwrite/Auth/Key.php b/src/Appwrite/Auth/Key.php index b23f2cc816..7f2d27ed5e 100644 --- a/src/Appwrite/Auth/Key.php +++ b/src/Appwrite/Auth/Key.php @@ -15,6 +15,8 @@ class Key { public function __construct( protected string $projectId, + protected string $teamId, + protected string $userId, protected string $type, protected string $role, protected array $scopes, @@ -99,6 +101,8 @@ class Key */ public static function decode( Document $project, + Document $team, + Document $user, string $key ): Key { if (\str_contains($key, '_')) { @@ -115,6 +119,8 @@ class Key $guestKey = new Key( $project->getId(), + '', + '', $type, User::ROLE_GUESTS, $roles[User::ROLE_GUESTS]['scopes'] ?? [], @@ -152,6 +158,8 @@ class Key return new Key( $projectId, + '', + '', $type, $role, $scopes, @@ -185,12 +193,90 @@ class Key return new Key( $project->getId(), + '', + '', $type, $role, $scopes, $name, $expired ); + case API_KEY_ACCOUNT: + $key = $user->find( + key: 'secret', + find: $key, + subject: 'keys' + ); + + // Invalid key + if (!$key) { + return $guestKey; + } + + $expire = $key->getAttribute('expire'); + $expired = false; + if (!empty($expire) && $expire < DateTime::formatTz(DateTime::now())) { + $expired = true; + } + + $name = $key->getAttribute('name', 'UNKNOWN'); + + $role = User::ROLE_USERS; + + $roles = Config::getParam('roles', []); + $scopes = $roles[$role]['scopes'] ?? []; + $scopes = $key->getAttribute('scopes', []); + + $key = new Key( + '', + '', + $user->getId(), + $type, + $role, + $scopes, + $name, + $expired + ); + + return $key; + case API_KEY_ORGANIZATION: + $key = $team->find( + key: 'secret', + find: $key, + subject: 'keys' + ); + + // Invalid key + if (!$key) { + return $guestKey; + } + + $expire = $key->getAttribute('expire'); + $expired = false; + if (!empty($expire) && $expire < DateTime::formatTz(DateTime::now())) { + $expired = true; + } + + $name = $key->getAttribute('name', 'UNKNOWN'); + + $role = User::ROLE_APPS; + + $roles = Config::getParam('roles', []); + $scopes = $roles[$role]['scopes'] ?? []; + $scopes = $key->getAttribute('scopes', []); + + $key = new Key( + '', + $team->getId(), + '', + $type, + $role, + $scopes, + $name, + $expired + ); + + return $key; default: return $guestKey; } diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php index 5c226c5925..94667a9fac 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php @@ -87,7 +87,7 @@ class Create extends Base ->param('logging', true, new Boolean(), 'When disabled, executions will exclude logs and errors, and will be slightly faster.', true) ->param('entrypoint', '', new Text(1028, 0), 'Entrypoint File. This path is relative to the "providerRootDirectory".', true) ->param('commands', '', new Text(8192, 0), 'Build Commands.', true) - ->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('scopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of scopes allowed for API key auto-generated for every execution. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.', true) + ->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of scopes allowed for API key auto-generated for every execution. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.', true) ->param('installationId', '', new Text(128, 0), 'Appwrite Installation ID for VCS (Version Control System) deployment.', true) ->param('providerRepositoryId', '', new Text(128, 0), 'Repository ID of the repo linked to the function.', true) ->param('providerBranch', '', new Text(128, 0), 'Production branch for the repo linked to the function.', true) diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php index adb29bc533..227ec3f026 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php @@ -83,7 +83,7 @@ class Update extends Base ->param('logging', true, new Boolean(), 'When disabled, executions will exclude logs and errors, and will be slightly faster.', true) ->param('entrypoint', '', new Text(1028, 0), 'Entrypoint File. This path is relative to the "providerRootDirectory".', true) ->param('commands', '', new Text(8192, 0), 'Build Commands.', true) - ->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('scopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of scopes allowed for API Key auto-generated for every execution. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.', true) + ->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of scopes allowed for API Key auto-generated for every execution. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.', true) ->param('installationId', '', new Text(128, 0), 'Appwrite Installation ID for VCS (Version Controle System) deployment.', true) ->param('providerRepositoryId', null, new Nullable(new Text(128, 0)), 'Repository ID of the repo linked to the function', true) ->param('providerBranch', '', new Text(128, 0), 'Production branch for the repo linked to the function', true) diff --git a/src/Appwrite/Platform/Tasks/Screenshot.php b/src/Appwrite/Platform/Tasks/Screenshot.php index 7ad95c6e72..4df3ab91df 100644 --- a/src/Appwrite/Platform/Tasks/Screenshot.php +++ b/src/Appwrite/Platform/Tasks/Screenshot.php @@ -190,7 +190,7 @@ class Screenshot extends Action 'cookie' => $cookieConsole ], [ 'name' => 'Screenshot API key', - 'scopes' => \array_keys(Config::getParam('scopes', [])) + 'scopes' => \array_keys(Config::getParam('projectScopes', [])) ]); if ($response['headers']['status-code'] !== 201) { From c0c1d693c267c23e61b0d34414f682690c2b6e93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 23 Dec 2025 13:06:25 +0100 Subject: [PATCH 16/39] DB schema update for keys --- app/config/collections/platform.php | 30 +++++++++++++++++++++++++++- app/init/database/filters.php | 31 +++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/app/config/collections/platform.php b/app/config/collections/platform.php index 16eafc9d4a..395a1c5d3b 100644 --- a/app/config/collections/platform.php +++ b/app/config/collections/platform.php @@ -6,7 +6,7 @@ use Utopia\Database\Helpers\ID; $providers = Config::getParam('oAuthProviders', []); -return [ +$platformCollections = [ 'projects' => [ '$collection' => ID::custom(Database::METADATA), '$id' => ID::custom('projects'), @@ -1914,3 +1914,31 @@ return [ 'indexes' => [] ], ]; + +// Organization API keys subquery +$platformCollections['teams']['attributes'][] = [ + '$id' => ID::custom('keys'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 16384, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => ['subQueryOrganizationKeys'], +]; + +// Account API keys subquery +$platformCollections['users']['attributes'][] = [ + '$id' => ID::custom('keys'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 16384, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => ['subQueryAccountKeys'], +]; + +return $platformCollections; diff --git a/app/init/database/filters.php b/app/init/database/filters.php index d8624c496e..166b3f7163 100644 --- a/app/init/database/filters.php +++ b/app/init/database/filters.php @@ -434,3 +434,34 @@ Database::addFilter( return $value; } ); + + +Database::addFilter( + 'subQueryOrganizationKeys', + function (mixed $value) { + return; + }, + function (mixed $value, Document $document, Database $database) { + return Authorization::skip(fn () => $database + ->find('keys', [ + Query::equal('resourceType', ['teams']), + Query::equal('resourceInternalId', [$document->getSequence()]), + Query::limit(APP_LIMIT_SUBQUERY), + ])); + } +); + +Database::addFilter( + 'subQueryAccountKeys', + function (mixed $value) { + return; + }, + function (mixed $value, Document $document, Database $database) { + return Authorization::skip(fn () => $database + ->find('keys', [ + Query::equal('resourceType', ['users']), + Query::equal('resourceInternalId', [$document->getSequence()]), + Query::limit(APP_LIMIT_SUBQUERY), + ])); + } +); From 9477a5d9802cd848b1bbeb914811a7816723364f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 23 Dec 2025 13:30:43 +0100 Subject: [PATCH 17/39] Fix extensability of collections --- app/config/collections.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/config/collections.php b/app/config/collections.php index 533dee57a8..a74e079dce 100644 --- a/app/config/collections.php +++ b/app/config/collections.php @@ -26,8 +26,8 @@ unset($common['files']); $collections = [ 'buckets' => $buckets, 'databases' => $databases, - 'projects' => array_merge($projects, $common), - 'console' => array_merge($platform, $common), + 'projects' => array_merge_recursive($projects, $common), + 'console' => array_merge_recursive($platform, $common), 'logs' => $logs, ]; From c08acedf6ac05e128cb33bbebde1522bbdbec984 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 23 Dec 2025 15:12:41 +0100 Subject: [PATCH 18/39] Fix key test --- tests/unit/Auth/KeyTest.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/unit/Auth/KeyTest.php b/tests/unit/Auth/KeyTest.php index 920608e82f..727162433a 100644 --- a/tests/unit/Auth/KeyTest.php +++ b/tests/unit/Auth/KeyTest.php @@ -24,8 +24,12 @@ class KeyTest extends TestCase $roleScopes = Config::getParam('roles', [])[User::ROLE_APPS]['scopes']; $key = static::generateKey($projectId, $usage, $scopes); - $project = new Document(['$id' => $projectId,]); - $decoded = Key::decode($project, $key); + $decoded = Key::decode( + project: new Document(['$id' => $projectId]), + team: new Document(), + user: new Document(), + key: $key, + ); $this->assertEquals($projectId, $decoded->getProjectId()); $this->assertEquals(API_KEY_DYNAMIC, $decoded->getType()); From c54d1d29a58a5cbfb92c90d2d5510029e75094da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sat, 27 Dec 2025 18:44:01 +0100 Subject: [PATCH 19/39] Update stats of all key ypes --- app/controllers/shared/api.php | 52 +++++++++++++++++++++++++--------- src/Appwrite/Auth/Key.php | 10 +++++++ 2 files changed, 49 insertions(+), 13 deletions(-) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index c4ca334921..e1786a30d0 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -330,22 +330,46 @@ App::init() // For standard keys, update last accessed time if ($apiKey->getType() === API_KEY_STANDARD) { - $dbKey = $project->find( - key: 'secret', - find: $request->getHeader('x-appwrite-key', ''), - subject: 'keys' - ); + if (!empty($apiKey->getProjectId())) { + $dbKey = $project->find( + key: 'secret', + find: $request->getHeader('x-appwrite-key', ''), + subject: 'keys' + ); + } elseif (!empty($apiKey->getUserId())) { + $dbKey = $user->find( + key: 'secret', + find: $request->getHeader('x-appwrite-key', ''), + subject: 'keys' + ); + } elseif (!empty($apiKey->getTeamId())) { + $dbKey = $team->find( + key: 'secret', + find: $request->getHeader('x-appwrite-key', ''), + subject: 'keys' + ); + } if (!$dbKey) { throw new Exception(Exception::USER_UNAUTHORIZED); } + $purgeResource = function () use ($apiKey, $dbForPlatform, $project, $user, $team) { + if (!empty($apiKey->getProjectId())) { + $dbForPlatform->purgeCachedDocument('projects', $project->getId()); + } elseif (!empty($apiKey->getUserId())) { + $dbForPlatform->purgeCachedDocument('users', $user->getId()); + } elseif (!empty($apiKey->getTeamId())) { + $dbForPlatform->purgeCachedDocument('teams', $team->getId()); + } + }; + + $updates = new Document(); + $accessedAt = $dbKey->getAttribute('accessedAt', 0); if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_KEY_ACCESS)) > $accessedAt) { - $dbKey->setAttribute('accessedAt', DateTime::now()); - $dbForPlatform->updateDocument('keys', $dbKey->getId(), $dbKey); - $dbForPlatform->purgeCachedDocument('projects', $project->getId()); + $updates->setAttribute('accessedAt', DateTime::now()); } $sdkValidator = new WhiteList($servers, true); @@ -356,15 +380,17 @@ App::init() if (!in_array($sdk, $sdks)) { $sdks[] = $sdk; - $dbKey->setAttribute('sdks', $sdks); - /** Update access time as well */ - $dbKey->setAttribute('accessedAt', Datetime::now()); - $dbForPlatform->updateDocument('keys', $dbKey->getId(), $dbKey); - $dbForPlatform->purgeCachedDocument('projects', $project->getId()); + $updates->setAttribute('sdks', $sdks); + $updates->setAttribute('accessedAt', Datetime::now()); } } + if (!$updates->isEmpty()) { + $dbForPlatform->updateDocument('keys', $dbKey->getId(), $updates); + $purgeResource(); + } + $queueForAudits->setUser($user); } } // Admin User Authentication diff --git a/src/Appwrite/Auth/Key.php b/src/Appwrite/Auth/Key.php index 7f2d27ed5e..c4310164cc 100644 --- a/src/Appwrite/Auth/Key.php +++ b/src/Appwrite/Auth/Key.php @@ -36,6 +36,16 @@ class Key return $this->projectId; } + public function getUserId(): string + { + return $this->userId; + } + + public function getTeamId(): string + { + return $this->teamId; + } + public function getType(): string { return $this->type; From ee0f15eed64a4ec896fe1a11e6628a851903d99a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sat, 27 Dec 2025 19:08:12 +0100 Subject: [PATCH 20/39] QA bug fixing --- app/controllers/shared/api.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index e1786a30d0..58e81a4868 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -329,7 +329,7 @@ App::init() } // For standard keys, update last accessed time - if ($apiKey->getType() === API_KEY_STANDARD) { + if (\in_array($apiKey->getType(), [API_KEY_STANDARD, API_KEY_ORGANIZATION, API_KEY_ACCOUNT])) { if (!empty($apiKey->getProjectId())) { $dbKey = $project->find( key: 'secret', @@ -356,11 +356,11 @@ App::init() $purgeResource = function () use ($apiKey, $dbForPlatform, $project, $user, $team) { if (!empty($apiKey->getProjectId())) { - $dbForPlatform->purgeCachedDocument('projects', $project->getId()); + Authorization::skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); } elseif (!empty($apiKey->getUserId())) { - $dbForPlatform->purgeCachedDocument('users', $user->getId()); + Authorization::skip(fn () => $dbForPlatform->purgeCachedDocument('users', $user->getId())); } elseif (!empty($apiKey->getTeamId())) { - $dbForPlatform->purgeCachedDocument('teams', $team->getId()); + Authorization::skip(fn () => $dbForPlatform->purgeCachedDocument('teams', $team->getId())); } }; @@ -387,7 +387,7 @@ App::init() } if (!$updates->isEmpty()) { - $dbForPlatform->updateDocument('keys', $dbKey->getId(), $updates); + Authorization::skip(fn () => $dbForPlatform->updateDocument('keys', $dbKey->getId(), $updates)); $purgeResource(); } From 6774de4eef7987955c794d0b1e3043240e260ee0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sat, 27 Dec 2025 19:18:25 +0100 Subject: [PATCH 21/39] add todo --- tests/unit/Auth/KeyTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/Auth/KeyTest.php b/tests/unit/Auth/KeyTest.php index 727162433a..ab577e9c2f 100644 --- a/tests/unit/Auth/KeyTest.php +++ b/tests/unit/Auth/KeyTest.php @@ -10,6 +10,7 @@ use Utopia\Config\Config; use Utopia\Database\Document; use Utopia\System\System; +// TODO: Check diff of Key.php, and update unit tests accordingly class KeyTest extends TestCase { public function testDecode(): void From b4c1b96d43277360d787d67a205558f00d86a836 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sat, 27 Dec 2025 19:28:08 +0100 Subject: [PATCH 22/39] Fix General tests --- app/controllers/general.php | 2 +- app/init/resources.php | 2 +- docker-compose.yml | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/controllers/general.php b/app/controllers/general.php index 23de89af27..c3ceb07d09 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -1402,7 +1402,7 @@ App::error() $template = $error->getView() ?? (($route) ? $route->getLabel('error', null) : null); // TODO: Ideally use group 'api' here, but all wildcard routes seem to have 'api' at the moment - if (!\str_starts_with($route->getPath(), '/v1')) { + if (empty($route) || !\str_starts_with($route->getPath(), '/v1')) { $template = __DIR__ . '/../views/general/error.phtml'; } diff --git a/app/init/resources.php b/app/init/resources.php index 1db546e0d0..77bae318b8 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -1017,7 +1017,7 @@ App::setResource('team', function (Document $project, Database $dbForPlatform, A $teamInternalId = $project->getAttribute('teamInternalId', ''); } else { $route = $utopia->match($request); - $path = $route->getPath(); + $path = !empty($route) ? $route->getPath() : $request->getURI(); if (str_starts_with($path, '/v1/projects/:projectId')) { $uri = $request->getURI(); $pid = explode('/', $uri)[3]; diff --git a/docker-compose.yml b/docker-compose.yml index 3b935b84fb..c045ad1647 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -98,6 +98,7 @@ services: - ./public:/usr/src/code/public - ./src:/usr/src/code/src - ./dev:/usr/src/code/dev + # - ./vendor/utopia-php/framework:/usr/src/code/vendor/utopia-php/framework depends_on: - mariadb - redis From 04f660e44bc98cd874cacf00289797208f5b5c6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sun, 28 Dec 2025 10:06:29 +0100 Subject: [PATCH 23/39] Dedicate project test --- .../Projects/ProjectsConsoleClientTest.php | 234 +++++++++--------- 1 file changed, 116 insertions(+), 118 deletions(-) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 769d3a4c85..f0608595f7 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1765,124 +1765,6 @@ class ProjectsConsoleClientTest extends Scope return $data; } - /** - * @depends testUpdateProjectAuthLimit - */ - public function testUpdateProjectAuthSessionsLimit($data): array - { - $id = $data['projectId'] ?? ''; - - /** - * Test for failure - */ - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'limit' => 0, - ]); - - $this->assertEquals(400, $response['headers']['status-code']); - - /** - * Test for SUCCESS - */ - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'limit' => 1, - ]); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertNotEmpty($response['body']['$id']); - $this->assertEquals(1, $response['body']['authSessionsLimit']); - - $email = uniqid() . 'user@localhost.test'; - $password = 'password'; - $name = 'User Name'; - - /** - * Create new user - */ - $response = $this->client->call(Client::METHOD_POST, '/account', array_merge([ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $id, - ]), [ - 'userId' => ID::unique(), - 'email' => $email, - 'password' => $password, - 'name' => $name, - ]); - - $this->assertEquals(201, $response['headers']['status-code']); - - /** - * create new session - */ - $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $id, - ]), [ - 'email' => $email, - 'password' => $password, - ]); - - - $this->assertEquals(201, $response['headers']['status-code']); - $sessionId1 = $response['body']['$id']; - - /** - * create new session - */ - $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $id, - ]), [ - 'email' => $email, - 'password' => $password, - ]); - - - $this->assertEquals(201, $response['headers']['status-code']); - $sessionCookie = $response['headers']['set-cookie']; - $sessionId2 = $response['body']['$id']; - - /** - * List sessions - */ - $this->assertEventually(function () use ($id, $sessionCookie, $sessionId2) { - $response = $this->client->call(Client::METHOD_GET, '/account/sessions', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $id, - 'Cookie' => $sessionCookie, - ]); - - $this->assertEquals(200, $response['headers']['status-code']); - $sessions = $response['body']['sessions']; - - $this->assertEquals(1, count($sessions)); - $this->assertEquals($sessionId2, $sessions[0]['$id']); - }); - - /** - * Reset Limit - */ - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'limit' => 10, - ]); - - return $data; - } - - /** * @depends testUpdateProjectAuthLimit */ @@ -5380,4 +5262,120 @@ class ProjectsConsoleClientTest extends Scope /** * Devkeys Tests ends here ------------------------------------------------ */ + + public function testUpdateProjectAuthSessionsLimit(): void + { + $id = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'testUpdateProjectAuthSessionsLimit', + 'region' => System::getEnv('_APP_REGION', 'default') + ]); + + /** + * Test for failure + */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'limit' => 0, + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + /** + * Test for SUCCESS + */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'limit' => 1, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertEquals(1, $response['body']['authSessionsLimit']); + + $email = uniqid() . 'user@localhost.test'; + $password = 'password'; + $name = 'User Name'; + + /** + * Create new user + */ + $response = $this->client->call(Client::METHOD_POST, '/account', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + ]), [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => $name, + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + + /** + * create new session + */ + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + ]), [ + 'email' => $email, + 'password' => $password, + ]); + + + $this->assertEquals(201, $response['headers']['status-code']); + $sessionId1 = $response['body']['$id']; + + /** + * create new session + */ + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + ]), [ + 'email' => $email, + 'password' => $password, + ]); + + + $this->assertEquals(201, $response['headers']['status-code']); + $sessionCookie = $response['headers']['set-cookie']; + $sessionId2 = $response['body']['$id']; + + /** + * List sessions + */ + $this->assertEventually(function () use ($id, $sessionCookie, $sessionId2) { + $response = $this->client->call(Client::METHOD_GET, '/account/sessions', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'Cookie' => $sessionCookie, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $sessions = $response['body']['sessions']; + + $this->assertEquals(1, count($sessions)); + $this->assertEquals($sessionId2, $sessions[0]['$id']); + }); + + /** + * Reset Limit + */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'limit' => 10, + ]); + } } From 7c56a76feb7bacf10f302f15afc23ae333044642 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 29 Dec 2025 08:59:07 +0100 Subject: [PATCH 24/39] self PR review fixes --- app/config/scopes/account.php | 6 +- app/init/resources.php | 9 +- src/Appwrite/Utopia/Response/Model/Key.php | 2 +- .../Projects/ProjectsConsoleClientTest.php | 234 +++++++++--------- 4 files changed, 131 insertions(+), 120 deletions(-) diff --git a/app/config/scopes/account.php b/app/config/scopes/account.php index f11e49ca76..ec98281458 100644 --- a/app/config/scopes/account.php +++ b/app/config/scopes/account.php @@ -5,9 +5,11 @@ return [ "account" => [ "description" => 'Access to manage account, it\'s organizations, sessions, tokens, and billing.', - ],"teams.read" => [ + ], + "teams.read" => [ "description" => 'Access to read account\'s organizations.', - ],"teams.write" => [ + ], + "teams.write" => [ "description" => 'Access to create, update and delete account\'s organizations and it\'s memberships.', ], ]; diff --git a/app/init/resources.php b/app/init/resources.php index 77bae318b8..236f861ef0 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -44,6 +44,7 @@ use Utopia\Config\Config; use Utopia\Database\Adapter\Pool as DatabasePool; use Utopia\Database\Database; use Utopia\Database\DateTime as DatabaseDateTime; +use Utopia\Database\DateTime as DatabaseDateTime; use Utopia\Database\Document; use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; @@ -444,7 +445,13 @@ App::setResource('user', function (string $mode, Document $project, Document $co subject: 'keys' ); - if (!empty($key)) { + $expired = false; + $expire = $key->getAttribute('expire'); + if (!empty($expire) && $expire < DatabaseDateTime::formatTz(DatabaseDateTime::now())) { + $expired = true; + } + + if (!empty($key) && !$expired) { $user = $accountKeyUser; } } diff --git a/src/Appwrite/Utopia/Response/Model/Key.php b/src/Appwrite/Utopia/Response/Model/Key.php index 38aa0748df..a13c9146cd 100644 --- a/src/Appwrite/Utopia/Response/Model/Key.php +++ b/src/Appwrite/Utopia/Response/Model/Key.php @@ -10,7 +10,7 @@ class Key extends Model /** * @var bool */ - protected bool $public = true; + protected bool $public = true; // Public because reused for more key types public function __construct() { diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index f0608595f7..f2887d951b 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -2079,6 +2079,124 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(201, $response['headers']['status-code']); } + public function testUpdateProjectAuthSessionsLimit(): void + { + $id = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'testUpdateProjectAuthSessionsLimit', + 'region' => System::getEnv('_APP_REGION', 'default') + ]); + + /** + * Test for failure + */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'limit' => 0, + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + /** + * Test for SUCCESS + */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'limit' => 1, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertEquals(1, $response['body']['authSessionsLimit']); + + $email = uniqid() . 'user@localhost.test'; + $password = 'password'; + $name = 'User Name'; + + /** + * Create new user + */ + $response = $this->client->call(Client::METHOD_POST, '/account', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + ]), [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => $name, + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + + /** + * create new session + */ + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + ]), [ + 'email' => $email, + 'password' => $password, + ]); + + + $this->assertEquals(201, $response['headers']['status-code']); + $sessionId1 = $response['body']['$id']; + + /** + * create new session + */ + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + ]), [ + 'email' => $email, + 'password' => $password, + ]); + + + $this->assertEquals(201, $response['headers']['status-code']); + $sessionCookie = $response['headers']['set-cookie']; + $sessionId2 = $response['body']['$id']; + + /** + * List sessions + */ + $this->assertEventually(function () use ($id, $sessionCookie, $sessionId2) { + $response = $this->client->call(Client::METHOD_GET, '/account/sessions', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'Cookie' => $sessionCookie, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $sessions = $response['body']['sessions']; + + $this->assertEquals(1, count($sessions)); + $this->assertEquals($sessionId2, $sessions[0]['$id']); + }); + + /** + * Reset Limit + */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'limit' => 10, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + } + /** * @depends testUpdateProjectAuthLimit */ @@ -5262,120 +5380,4 @@ class ProjectsConsoleClientTest extends Scope /** * Devkeys Tests ends here ------------------------------------------------ */ - - public function testUpdateProjectAuthSessionsLimit(): void - { - $id = $this->setupProject([ - 'projectId' => ID::unique(), - 'name' => 'testUpdateProjectAuthSessionsLimit', - 'region' => System::getEnv('_APP_REGION', 'default') - ]); - - /** - * Test for failure - */ - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'limit' => 0, - ]); - - $this->assertEquals(400, $response['headers']['status-code']); - - /** - * Test for SUCCESS - */ - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'limit' => 1, - ]); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertNotEmpty($response['body']['$id']); - $this->assertEquals(1, $response['body']['authSessionsLimit']); - - $email = uniqid() . 'user@localhost.test'; - $password = 'password'; - $name = 'User Name'; - - /** - * Create new user - */ - $response = $this->client->call(Client::METHOD_POST, '/account', array_merge([ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $id, - ]), [ - 'userId' => ID::unique(), - 'email' => $email, - 'password' => $password, - 'name' => $name, - ]); - - $this->assertEquals(201, $response['headers']['status-code']); - - /** - * create new session - */ - $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $id, - ]), [ - 'email' => $email, - 'password' => $password, - ]); - - - $this->assertEquals(201, $response['headers']['status-code']); - $sessionId1 = $response['body']['$id']; - - /** - * create new session - */ - $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $id, - ]), [ - 'email' => $email, - 'password' => $password, - ]); - - - $this->assertEquals(201, $response['headers']['status-code']); - $sessionCookie = $response['headers']['set-cookie']; - $sessionId2 = $response['body']['$id']; - - /** - * List sessions - */ - $this->assertEventually(function () use ($id, $sessionCookie, $sessionId2) { - $response = $this->client->call(Client::METHOD_GET, '/account/sessions', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $id, - 'Cookie' => $sessionCookie, - ]); - - $this->assertEquals(200, $response['headers']['status-code']); - $sessions = $response['body']['sessions']; - - $this->assertEquals(1, count($sessions)); - $this->assertEquals($sessionId2, $sessions[0]['$id']); - }); - - /** - * Reset Limit - */ - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'limit' => 10, - ]); - } } From 5f5d9b4fcb1fd9cbbe4e2be9414147e5b3d40e34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 29 Dec 2025 09:04:47 +0100 Subject: [PATCH 25/39] Add async key cleanup --- app/controllers/api/teams.php | 12 +++++++-- app/init/constants.php | 1 + src/Appwrite/Platform/Workers/Deletes.php | 30 +++++++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index 8771588d3a..661e99ef1b 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -431,15 +431,23 @@ App::delete('/v1/teams/:teamId') throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove team from DB'); } + $clone = clone $team; + + // Sync delete $deletes = new Deletes(); - $deletes->deleteMemberships($getProjectDB, $team, $project); + $deletes->deleteMemberships($getProjectDB, $clone, $project); if ($project->getId() === 'console') { $queueForDeletes ->setType(DELETE_TYPE_TEAM_PROJECTS) - ->setDocument($team); + ->setDocument($clone); } + // Async delete + $queueForDeletes + ->setType(DELETE_TYPE_DOCUMENT) + ->setDocument($clone); + $queueForEvents ->setParam('teamId', $team->getId()) ->setPayload($response->output($team, Response::MODEL_TEAM)) diff --git a/app/init/constants.php b/app/init/constants.php index 3a8eb72e62..b27c681bcd 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -189,6 +189,7 @@ const DELETE_TYPE_SITES = 'sites'; const DELETE_TYPE_FUNCTIONS = 'functions'; const DELETE_TYPE_DEPLOYMENTS = 'deployments'; const DELETE_TYPE_USERS = 'users'; +const DELETE_TYPE_TEAMS = 'teams'; const DELETE_TYPE_TEAM_PROJECTS = 'teams_projects'; const DELETE_TYPE_EXECUTIONS = 'executions'; const DELETE_TYPE_AUDIT = 'audit'; diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index 5cd2402783..2f9ecaeee1 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -120,6 +120,9 @@ class Deletes extends Action case DELETE_TYPE_USERS: $this->deleteUser($getProjectDB, $document, $project); break; + case DELETE_TYPE_TEAMS: + $this->deleteTeam($getProjectDB, $document, $project); + break; case DELETE_TYPE_BUCKETS: $this->deleteBucket($getProjectDB, $deviceForFiles, $document, $project); break; @@ -634,6 +637,24 @@ class Deletes extends Action $deviceForCache->delete($deviceForCache->getRoot(), true); } + private function deleteTeam(callable $getProjectDB, Document $document, Document $project): void + { + $teamId = $document->getId(); + $teamInternalId = $document->getSequence(); + $dbForProject = $getProjectDB($project); + + if ($project->getId() === 'console') { + // Delete Keys + $this->deleteByGroup('keys', [ + Query::equal('resourceInternalId', [$teamInternalId]), + Query::equal('resourceType', ['teams']), + Query::orderAsc() + ], $dbForProject); + } + + $dbForProject->purgeCachedDocument('teams', $teamId); + } + /** * @param callable $getProjectDB * @param Document $document user document @@ -653,6 +674,15 @@ class Deletes extends Action Query::orderAsc() ], $dbForProject); + if ($project->getId() === 'console') { + // Delete Keys + $this->deleteByGroup('keys', [ + Query::equal('resourceInternalId', [$userInternalId]), + Query::equal('resourceType', ['users']), + Query::orderAsc() + ], $dbForProject); + } + $dbForProject->purgeCachedDocument('users', $userId); // Delete Memberships and decrement team membership counts From eb2c616089aefc69b5afdf3038f407674012c5e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 29 Dec 2025 10:47:27 +0100 Subject: [PATCH 26/39] Improve key unit tests --- app/config/scopes/account.php | 2 +- app/init/resources.php | 17 +++--- tests/unit/Auth/KeyTest.php | 102 +++++++++++++++++++++++++++++++++- 3 files changed, 111 insertions(+), 10 deletions(-) diff --git a/app/config/scopes/account.php b/app/config/scopes/account.php index ec98281458..5041408b2d 100644 --- a/app/config/scopes/account.php +++ b/app/config/scopes/account.php @@ -4,7 +4,7 @@ return [ "account" => [ - "description" => 'Access to manage account, it\'s organizations, sessions, tokens, and billing.', + "description" => 'Access to manage account, its organizations, sessions, tokens, and billing.', ], "teams.read" => [ "description" => 'Access to read account\'s organizations.', diff --git a/app/init/resources.php b/app/init/resources.php index 236f861ef0..b0ee04fbc6 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -44,7 +44,6 @@ use Utopia\Config\Config; use Utopia\Database\Adapter\Pool as DatabasePool; use Utopia\Database\Database; use Utopia\Database\DateTime as DatabaseDateTime; -use Utopia\Database\DateTime as DatabaseDateTime; use Utopia\Database\Document; use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; @@ -445,14 +444,16 @@ App::setResource('user', function (string $mode, Document $project, Document $co subject: 'keys' ); - $expired = false; - $expire = $key->getAttribute('expire'); - if (!empty($expire) && $expire < DatabaseDateTime::formatTz(DatabaseDateTime::now())) { - $expired = true; - } + if (!empty($key)) { + $expired = false; + $expire = $key->getAttribute('expire'); + if (!empty($expire) && $expire < DatabaseDateTime::formatTz(DatabaseDateTime::now())) { + $expired = true; + } - if (!empty($key) && !$expired) { - $user = $accountKeyUser; + if (!$expired) { + $user = $accountKeyUser; + } } } } diff --git a/tests/unit/Auth/KeyTest.php b/tests/unit/Auth/KeyTest.php index ab577e9c2f..b713513ff5 100644 --- a/tests/unit/Auth/KeyTest.php +++ b/tests/unit/Auth/KeyTest.php @@ -10,11 +10,11 @@ use Utopia\Config\Config; use Utopia\Database\Document; use Utopia\System\System; -// TODO: Check diff of Key.php, and update unit tests accordingly class KeyTest extends TestCase { public function testDecode(): void { + // Decode dynamic key $projectId = 'test'; $usage = false; $scopes = [ @@ -23,6 +23,7 @@ class KeyTest extends TestCase 'documents.read', ]; $roleScopes = Config::getParam('roles', [])[User::ROLE_APPS]['scopes']; + $guestRoleScopes = Config::getParam('roles', [])[User::ROLE_GUESTS]['scopes']; $key = static::generateKey($projectId, $usage, $scopes); $decoded = Key::decode( @@ -33,9 +34,108 @@ class KeyTest extends TestCase ); $this->assertEquals($projectId, $decoded->getProjectId()); + $this->assertEquals('', $decoded->getTeamId()); + $this->assertEquals('', $decoded->getUserId()); $this->assertEquals(API_KEY_DYNAMIC, $decoded->getType()); $this->assertEquals(User::ROLE_APPS, $decoded->getRole()); $this->assertEquals(\array_merge($scopes, $roleScopes), $decoded->getScopes()); + + // Decode standard key + $scopes = ['custom.write']; + $decoded = Key::decode( + project: new Document(['$id' => $projectId, 'keys' => [ + new Document([ + 'secret' => 'standard_abcd1234', + 'expire' => null, + 'name' => 'Standard key', + 'scopes' => $scopes + ]) + ]]), + team: new Document(), + user: new Document(), + key: 'standard_abcd1234', + ); + $this->assertEquals($projectId, $decoded->getProjectId()); + $this->assertEquals('', $decoded->getTeamId()); + $this->assertEquals('', $decoded->getUserId()); + $this->assertEquals(API_KEY_STANDARD, $decoded->getType()); + $this->assertEquals(User::ROLE_APPS, $decoded->getRole()); + $this->assertEquals(\array_merge($scopes, $roleScopes), $decoded->getScopes()); + + // Decode depricated standard key + $scopes = ['custom.write']; + $decoded = Key::decode( + project: new Document(['$id' => $projectId, 'keys' => [ + new Document([ + 'secret' => 'abcd1234', + 'expire' => null, + 'name' => 'Standard key', + 'scopes' => ['custom.write'] + ]) + ]]), + team: new Document(), + user: new Document(), + key: 'abcd1234', + ); + $this->assertEquals($projectId, $decoded->getProjectId()); + $this->assertEquals('', $decoded->getTeamId()); + $this->assertEquals('', $decoded->getUserId()); + $this->assertEquals(API_KEY_STANDARD, $decoded->getType()); + $this->assertEquals(User::ROLE_APPS, $decoded->getRole()); + $this->assertEquals(\array_merge($scopes, $roleScopes), $decoded->getScopes()); + + // Decode invalid standard key + $scopes = ['custom.write']; + $decoded = Key::decode( + project: new Document(['$id' => $projectId, 'keys' => [ + new Document([ + 'secret' => 'standard_abcd1234', + 'expire' => null, + 'name' => 'Standard key', + 'scopes' => ['custom.write'] + ]) + ]]), + team: new Document(), + user: new Document(), + key: 'standard_efgh5678', + ); + $this->assertEquals($projectId, $decoded->getProjectId()); + $this->assertEquals('', $decoded->getTeamId()); + $this->assertEquals('', $decoded->getUserId()); + $this->assertEquals(API_KEY_STANDARD, $decoded->getType()); + $this->assertEquals(User::ROLE_GUESTS, $decoded->getRole()); + $this->assertEquals($guestRoleScopes, $decoded->getScopes()); + + // Decode expired standard key + $scopes = ['custom.write']; + $yesterday = (new \DateTimeImmutable('-1 day'))->format('Y-m-d\TH:i:s\Z'); + $decoded = Key::decode( + project: new Document(['$id' => $projectId, 'keys' => [ + new Document([ + 'secret' => 'standard_abcd1234', + 'expire' => $yesterday, + 'name' => 'Standard key', + 'scopes' => $scopes + ]) + ]]), + team: new Document(), + user: new Document(), + key: 'standard_abcd1234', + ); + $this->assertEquals(true, $decoded->isExpired()); + $this->assertEquals($projectId, $decoded->getProjectId()); + $this->assertEquals('', $decoded->getTeamId()); + $this->assertEquals('', $decoded->getUserId()); + $this->assertEquals(API_KEY_STANDARD, $decoded->getType()); + $this->assertEquals(User::ROLE_APPS, $decoded->getRole()); + $this->assertEquals(\array_merge($scopes, $roleScopes), $decoded->getScopes()); + + // Decode account key + // Decode invalid account key + // Decode expired account key + // Decode organization key + // Decode invalid organization key + // Decode exired organization key } private static function generateKey( From ee911e3df613ee2550427b8435b9e4b2d9b55e77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 29 Dec 2025 11:21:49 +0100 Subject: [PATCH 27/39] Finalize unit key tests --- src/Appwrite/Auth/Key.php | 5 +- tests/unit/Auth/KeyTest.php | 215 +++++++++++++++++++++++++++++++++++- 2 files changed, 212 insertions(+), 8 deletions(-) diff --git a/src/Appwrite/Auth/Key.php b/src/Appwrite/Auth/Key.php index c4310164cc..8f645f6f08 100644 --- a/src/Appwrite/Auth/Key.php +++ b/src/Appwrite/Auth/Key.php @@ -146,6 +146,7 @@ class Key leeway: 0 ); + $payload = []; try { $payload = $jwtObj->decode($secret); } catch (JWTException) { @@ -233,8 +234,6 @@ class Key $role = User::ROLE_USERS; - $roles = Config::getParam('roles', []); - $scopes = $roles[$role]['scopes'] ?? []; $scopes = $key->getAttribute('scopes', []); $key = new Key( @@ -271,8 +270,6 @@ class Key $role = User::ROLE_APPS; - $roles = Config::getParam('roles', []); - $scopes = $roles[$role]['scopes'] ?? []; $scopes = $key->getAttribute('scopes', []); $key = new Key( diff --git a/tests/unit/Auth/KeyTest.php b/tests/unit/Auth/KeyTest.php index b713513ff5..830ac29dd0 100644 --- a/tests/unit/Auth/KeyTest.php +++ b/tests/unit/Auth/KeyTest.php @@ -39,6 +39,70 @@ class KeyTest extends TestCase $this->assertEquals(API_KEY_DYNAMIC, $decoded->getType()); $this->assertEquals(User::ROLE_APPS, $decoded->getRole()); $this->assertEquals(\array_merge($scopes, $roleScopes), $decoded->getScopes()); + $this->assertEquals('Dynamic Key', $decoded->getName()); + + // Decode dyamic key with extras + $extra = [ + 'disabledMetrics' => ['metric123'], + 'hostnameOverride' => true, + 'bannerDisabled' => true, + 'projectCheckDisabled' => true, + 'previewAuthDisabled' => true, + 'deploymentStatusIgnored' => true, + ]; + $key = static::generateKey($projectId, $usage, $scopes, extra: $extra); + $decoded = Key::decode( + project: new Document(['$id' => $projectId]), + team: new Document(), + user: new Document(), + key: $key, + ); + $this->assertEquals($projectId, $decoded->getProjectId()); + $this->assertEquals('', $decoded->getTeamId()); + $this->assertEquals('', $decoded->getUserId()); + $this->assertEquals(API_KEY_DYNAMIC, $decoded->getType()); + $this->assertEquals(User::ROLE_APPS, $decoded->getRole()); + $this->assertEquals(\array_merge($scopes, $roleScopes), $decoded->getScopes()); + $this->assertEquals('Dynamic Key', $decoded->getName()); + $this->assertEquals(['metric123'], $decoded->getDisabledMetrics()); + $this->assertEquals(true, $decoded->getHostnameOverride()); + $this->assertEquals(true, $decoded->isBannerDisabled()); + $this->assertEquals(true, $decoded->isProjectCheckDisabled()); + $this->assertEquals(true, $decoded->isPreviewAuthDisabled()); + $this->assertEquals(true, $decoded->isDeploymentStatusIgnored()); + + // Decode invalid dynamic key + $invalidKey = API_KEY_DYNAMIC . '_invalid_jwt_token'; + $decoded = Key::decode( + project: new Document(['$id' => $projectId]), + team: new Document(), + user: new Document(), + key: $invalidKey, + ); + $this->assertEquals($projectId, $decoded->getProjectId()); + $this->assertEquals('', $decoded->getTeamId()); + $this->assertEquals('', $decoded->getUserId()); + $this->assertEquals(API_KEY_DYNAMIC, $decoded->getType()); + $this->assertEquals(User::ROLE_GUESTS, $decoded->getRole()); + $this->assertEquals($guestRoleScopes, $decoded->getScopes()); + $this->assertEquals('UNKNOWN', $decoded->getName()); + + // Decode expired dynamic key + $expiredKey = static::generateKey($projectId, $usage, $scopes, maxAge: 1, timestamp: time() - 60); + \sleep(2); + $decoded = Key::decode( + project: new Document(['$id' => $projectId]), + team: new Document(), + user: new Document(), + key: $expiredKey, + ); + $this->assertEquals($projectId, $decoded->getProjectId()); + $this->assertEquals('', $decoded->getTeamId()); + $this->assertEquals('', $decoded->getUserId()); + $this->assertEquals(API_KEY_DYNAMIC, $decoded->getType()); + $this->assertEquals(User::ROLE_GUESTS, $decoded->getRole()); + $this->assertEquals($guestRoleScopes, $decoded->getScopes()); + $this->assertEquals('UNKNOWN', $decoded->getName()); // Decode standard key $scopes = ['custom.write']; @@ -61,6 +125,7 @@ class KeyTest extends TestCase $this->assertEquals(API_KEY_STANDARD, $decoded->getType()); $this->assertEquals(User::ROLE_APPS, $decoded->getRole()); $this->assertEquals(\array_merge($scopes, $roleScopes), $decoded->getScopes()); + $this->assertEquals('Standard key', $decoded->getName()); // Decode depricated standard key $scopes = ['custom.write']; @@ -83,6 +148,7 @@ class KeyTest extends TestCase $this->assertEquals(API_KEY_STANDARD, $decoded->getType()); $this->assertEquals(User::ROLE_APPS, $decoded->getRole()); $this->assertEquals(\array_merge($scopes, $roleScopes), $decoded->getScopes()); + $this->assertEquals('Standard key', $decoded->getName()); // Decode invalid standard key $scopes = ['custom.write']; @@ -105,6 +171,7 @@ class KeyTest extends TestCase $this->assertEquals(API_KEY_STANDARD, $decoded->getType()); $this->assertEquals(User::ROLE_GUESTS, $decoded->getRole()); $this->assertEquals($guestRoleScopes, $decoded->getScopes()); + $this->assertEquals('UNKNOWN', $decoded->getName()); // Decode expired standard key $scopes = ['custom.write']; @@ -129,32 +196,172 @@ class KeyTest extends TestCase $this->assertEquals(API_KEY_STANDARD, $decoded->getType()); $this->assertEquals(User::ROLE_APPS, $decoded->getRole()); $this->assertEquals(\array_merge($scopes, $roleScopes), $decoded->getScopes()); + $this->assertEquals('Standard key', $decoded->getName()); // Decode account key + $userId = 'user123'; + $scopes = ['teams.write']; + $decoded = Key::decode( + project: new Document(['$id' => $projectId]), + team: new Document(), + user: new Document(['$id' => $userId, 'keys' => [ + new Document([ + 'secret' => 'account_abcd1234', + 'expire' => null, + 'name' => 'Account key', + 'scopes' => $scopes + ]) + ]]), + key: 'account_abcd1234', + ); + $this->assertEquals('', $decoded->getProjectId()); + $this->assertEquals('', $decoded->getTeamId()); + $this->assertEquals($userId, $decoded->getUserId()); + $this->assertEquals(API_KEY_ACCOUNT, $decoded->getType()); + $this->assertEquals(User::ROLE_USERS, $decoded->getRole()); + $this->assertEquals($scopes, $decoded->getScopes()); + $this->assertEquals('Account key', $decoded->getName()); + // Decode invalid account key + $scopes = ['teams.write']; + $decoded = Key::decode( + project: new Document(['$id' => $projectId]), + team: new Document(), + user: new Document(['$id' => $userId, 'keys' => [ + new Document([ + 'secret' => 'account_abcd1234', + 'expire' => null, + 'name' => 'Account key', + 'scopes' => $scopes + ]) + ]]), + key: 'account_efgh5678', + ); + $this->assertEquals($projectId, $decoded->getProjectId()); + $this->assertEquals('', $decoded->getTeamId()); + $this->assertEquals('', $decoded->getUserId()); + $this->assertEquals(API_KEY_ACCOUNT, $decoded->getType()); + $this->assertEquals(User::ROLE_GUESTS, $decoded->getRole()); + $this->assertEquals($guestRoleScopes, $decoded->getScopes()); + $this->assertEquals('UNKNOWN', $decoded->getName()); + // Decode expired account key + $scopes = ['teams.write']; + $decoded = Key::decode( + project: new Document(['$id' => $projectId]), + team: new Document(), + user: new Document(['$id' => $userId, 'keys' => [ + new Document([ + 'secret' => 'account_abcd1234', + 'expire' => $yesterday, + 'name' => 'Account key', + 'scopes' => $scopes + ]) + ]]), + key: 'account_abcd1234', + ); + $this->assertEquals(true, $decoded->isExpired()); + $this->assertEquals('', $decoded->getProjectId()); + $this->assertEquals('', $decoded->getTeamId()); + $this->assertEquals($userId, $decoded->getUserId()); + $this->assertEquals(API_KEY_ACCOUNT, $decoded->getType()); + $this->assertEquals(User::ROLE_USERS, $decoded->getRole()); + $this->assertEquals($scopes, $decoded->getScopes()); + $this->assertEquals('Account key', $decoded->getName()); + // Decode organization key + $teamId = 'team123'; + $scopes = ['projects.write']; + $decoded = Key::decode( + project: new Document(['$id' => $projectId]), + team: new Document(['$id' => $teamId, 'keys' => [ + new Document([ + 'secret' => 'organization_abcd1234', + 'expire' => null, + 'name' => 'Organization key', + 'scopes' => $scopes + ]) + ]]), + user: new Document(), + key: 'organization_abcd1234', + ); + $this->assertEquals('', $decoded->getProjectId()); + $this->assertEquals($teamId, $decoded->getTeamId()); + $this->assertEquals('', $decoded->getUserId()); + $this->assertEquals(API_KEY_ORGANIZATION, $decoded->getType()); + $this->assertEquals(User::ROLE_APPS, $decoded->getRole()); + $this->assertEquals($scopes, $decoded->getScopes()); + $this->assertEquals('Organization key', $decoded->getName()); + // Decode invalid organization key - // Decode exired organization key + $scopes = ['projects.write']; + $decoded = Key::decode( + project: new Document(['$id' => $projectId]), + team: new Document(['$id' => $teamId, 'keys' => [ + new Document([ + 'secret' => 'organization_abcd1234', + 'expire' => null, + 'name' => 'Organization key', + 'scopes' => $scopes + ]) + ]]), + user: new Document(), + key: 'organization_efgh5678', + ); + $this->assertEquals($projectId, $decoded->getProjectId()); + $this->assertEquals('', $decoded->getTeamId()); + $this->assertEquals('', $decoded->getUserId()); + $this->assertEquals(API_KEY_ORGANIZATION, $decoded->getType()); + $this->assertEquals(User::ROLE_GUESTS, $decoded->getRole()); + $this->assertEquals($guestRoleScopes, $decoded->getScopes()); + $this->assertEquals('UNKNOWN', $decoded->getName()); + + // Decode expired organization key + $scopes = ['projects.write']; + $decoded = Key::decode( + project: new Document(['$id' => $projectId]), + team: new Document(['$id' => $teamId, 'keys' => [ + new Document([ + 'secret' => 'organization_abcd1234', + 'expire' => $yesterday, + 'name' => 'Organization key', + 'scopes' => $scopes + ]) + ]]), + user: new Document(), + key: 'organization_abcd1234', + ); + $this->assertEquals(true, $decoded->isExpired()); + $this->assertEquals('', $decoded->getProjectId()); + $this->assertEquals($teamId, $decoded->getTeamId()); + $this->assertEquals('', $decoded->getUserId()); + $this->assertEquals(API_KEY_ORGANIZATION, $decoded->getType()); + $this->assertEquals(User::ROLE_APPS, $decoded->getRole()); + $this->assertEquals($scopes, $decoded->getScopes()); + $this->assertEquals('Organization key', $decoded->getName()); } private static function generateKey( string $projectId, bool $usage, array $scopes, + int $maxAge = 86400, + ?int $timestamp = null, + array $extra = [] ): string { $jwt = new JWT( key: System::getEnv('_APP_OPENSSL_KEY_V1'), algo: 'HS256', - maxAge: 86400, + maxAge: $maxAge, leeway: 0, ); + $jwt->setTestTimestamp($timestamp); - $apiKey = $jwt->encode([ + $apiKey = $jwt->encode(\array_merge([ 'projectId' => $projectId, 'usage' => $usage, 'scopes' => $scopes, - ]); + ], $extra)); return API_KEY_DYNAMIC . '_' . $apiKey; } From eda189dbf18e4bc1bd42c70f8bf2fd1eaa649779 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 29 Dec 2025 13:24:26 +0100 Subject: [PATCH 28/39] AI review improvements --- app/config/scopes/account.php | 2 +- app/controllers/api/teams.php | 5 +++-- app/controllers/shared/api.php | 1 + tests/unit/Auth/KeyTest.php | 4 ++-- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/app/config/scopes/account.php b/app/config/scopes/account.php index 5041408b2d..7705dfca8a 100644 --- a/app/config/scopes/account.php +++ b/app/config/scopes/account.php @@ -10,6 +10,6 @@ return [ "description" => 'Access to read account\'s organizations.', ], "teams.write" => [ - "description" => 'Access to create, update and delete account\'s organizations and it\'s memberships.', + "description" => 'Access to create, update and delete account\'s organizations and its memberships.', ], ]; diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index 661e99ef1b..1ec33742fb 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -437,13 +437,14 @@ App::delete('/v1/teams/:teamId') $deletes = new Deletes(); $deletes->deleteMemberships($getProjectDB, $clone, $project); + // Async delete if ($project->getId() === 'console') { $queueForDeletes ->setType(DELETE_TYPE_TEAM_PROJECTS) - ->setDocument($clone); + ->setDocument($clone) + ->trigger(); } - // Async delete $queueForDeletes ->setType(DELETE_TYPE_DOCUMENT) ->setDocument($clone); diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 58e81a4868..60d89df86b 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -330,6 +330,7 @@ App::init() // For standard keys, update last accessed time if (\in_array($apiKey->getType(), [API_KEY_STANDARD, API_KEY_ORGANIZATION, API_KEY_ACCOUNT])) { + $dbKey = null; if (!empty($apiKey->getProjectId())) { $dbKey = $project->find( key: 'secret', diff --git a/tests/unit/Auth/KeyTest.php b/tests/unit/Auth/KeyTest.php index 830ac29dd0..fc1779efad 100644 --- a/tests/unit/Auth/KeyTest.php +++ b/tests/unit/Auth/KeyTest.php @@ -41,7 +41,7 @@ class KeyTest extends TestCase $this->assertEquals(\array_merge($scopes, $roleScopes), $decoded->getScopes()); $this->assertEquals('Dynamic Key', $decoded->getName()); - // Decode dyamic key with extras + // Decode dynamic key with extras $extra = [ 'disabledMetrics' => ['metric123'], 'hostnameOverride' => true, @@ -127,7 +127,7 @@ class KeyTest extends TestCase $this->assertEquals(\array_merge($scopes, $roleScopes), $decoded->getScopes()); $this->assertEquals('Standard key', $decoded->getName()); - // Decode depricated standard key + // Decode deprecated standard key $scopes = ['custom.write']; $decoded = Key::decode( project: new Document(['$id' => $projectId, 'keys' => [ From 00b5236dea5b99fda9b2335bc7ffb34df50e5f1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 29 Dec 2025 18:41:35 +0100 Subject: [PATCH 29/39] simplify diff --- .../Projects/ProjectsConsoleClientTest.php | 236 +++++++++--------- 1 file changed, 118 insertions(+), 118 deletions(-) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index f2887d951b..7afb558c9b 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1765,6 +1765,124 @@ class ProjectsConsoleClientTest extends Scope return $data; } + public function testUpdateProjectAuthSessionsLimit(): void + { + $id = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'testUpdateProjectAuthSessionsLimit', + 'region' => System::getEnv('_APP_REGION', 'default') + ]); + + /** + * Test for failure + */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'limit' => 0, + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + /** + * Test for SUCCESS + */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'limit' => 1, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertEquals(1, $response['body']['authSessionsLimit']); + + $email = uniqid() . 'user@localhost.test'; + $password = 'password'; + $name = 'User Name'; + + /** + * Create new user + */ + $response = $this->client->call(Client::METHOD_POST, '/account', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + ]), [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => $name, + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + + /** + * create new session + */ + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + ]), [ + 'email' => $email, + 'password' => $password, + ]); + + + $this->assertEquals(201, $response['headers']['status-code']); + $sessionId1 = $response['body']['$id']; + + /** + * create new session + */ + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + ]), [ + 'email' => $email, + 'password' => $password, + ]); + + + $this->assertEquals(201, $response['headers']['status-code']); + $sessionCookie = $response['headers']['set-cookie']; + $sessionId2 = $response['body']['$id']; + + /** + * List sessions + */ + $this->assertEventually(function () use ($id, $sessionCookie, $sessionId2) { + $response = $this->client->call(Client::METHOD_GET, '/account/sessions', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'Cookie' => $sessionCookie, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $sessions = $response['body']['sessions']; + + $this->assertEquals(1, count($sessions)); + $this->assertEquals($sessionId2, $sessions[0]['$id']); + }); + + /** + * Reset Limit + */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'limit' => 10, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + } + /** * @depends testUpdateProjectAuthLimit */ @@ -2079,124 +2197,6 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(201, $response['headers']['status-code']); } - public function testUpdateProjectAuthSessionsLimit(): void - { - $id = $this->setupProject([ - 'projectId' => ID::unique(), - 'name' => 'testUpdateProjectAuthSessionsLimit', - 'region' => System::getEnv('_APP_REGION', 'default') - ]); - - /** - * Test for failure - */ - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'limit' => 0, - ]); - - $this->assertEquals(400, $response['headers']['status-code']); - - /** - * Test for SUCCESS - */ - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'limit' => 1, - ]); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertNotEmpty($response['body']['$id']); - $this->assertEquals(1, $response['body']['authSessionsLimit']); - - $email = uniqid() . 'user@localhost.test'; - $password = 'password'; - $name = 'User Name'; - - /** - * Create new user - */ - $response = $this->client->call(Client::METHOD_POST, '/account', array_merge([ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $id, - ]), [ - 'userId' => ID::unique(), - 'email' => $email, - 'password' => $password, - 'name' => $name, - ]); - - $this->assertEquals(201, $response['headers']['status-code']); - - /** - * create new session - */ - $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $id, - ]), [ - 'email' => $email, - 'password' => $password, - ]); - - - $this->assertEquals(201, $response['headers']['status-code']); - $sessionId1 = $response['body']['$id']; - - /** - * create new session - */ - $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $id, - ]), [ - 'email' => $email, - 'password' => $password, - ]); - - - $this->assertEquals(201, $response['headers']['status-code']); - $sessionCookie = $response['headers']['set-cookie']; - $sessionId2 = $response['body']['$id']; - - /** - * List sessions - */ - $this->assertEventually(function () use ($id, $sessionCookie, $sessionId2) { - $response = $this->client->call(Client::METHOD_GET, '/account/sessions', [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'x-appwrite-project' => $id, - 'Cookie' => $sessionCookie, - ]); - - $this->assertEquals(200, $response['headers']['status-code']); - $sessions = $response['body']['sessions']; - - $this->assertEquals(1, count($sessions)); - $this->assertEquals($sessionId2, $sessions[0]['$id']); - }); - - /** - * Reset Limit - */ - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/max-sessions', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'limit' => 10, - ]); - - $this->assertEquals(200, $response['headers']['status-code']); - } - /** * @depends testUpdateProjectAuthLimit */ From dad21a912e4f5638e6a51a756415c2a43a0ee740 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sat, 10 Jan 2026 16:35:09 +0100 Subject: [PATCH 30/39] PR review changes --- app/config/collections/platform.php | 4 ++-- app/controllers/api/teams.php | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/app/config/collections/platform.php b/app/config/collections/platform.php index 395a1c5d3b..73c21ed408 100644 --- a/app/config/collections/platform.php +++ b/app/config/collections/platform.php @@ -726,8 +726,8 @@ $platformCollections = [ '$id' => ID::custom('_key_resource'), 'type' => Database::INDEX_KEY, 'attributes' => ['resourceType', 'resourceInternalId'], - 'lengths' => [Database::LENGTH_KEY], - 'orders' => [Database::ORDER_ASC], + 'lengths' => [], + 'orders' => [], ], [ '$id' => '_key_accessedAt', diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index 1ec33742fb..fe87c31dd6 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -431,8 +431,6 @@ App::delete('/v1/teams/:teamId') throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove team from DB'); } - $clone = clone $team; - // Sync delete $deletes = new Deletes(); $deletes->deleteMemberships($getProjectDB, $clone, $project); From 60e1efb8cb47e339976c3b21382e60f2ddff9e7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sat, 10 Jan 2026 16:42:45 +0100 Subject: [PATCH 31/39] Merge conflict fix --- app/init/models.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/init/models.php b/app/init/models.php index fdfa0271b4..5cd32e73eb 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -177,7 +177,7 @@ Response::setModel(new BaseList('Deployments List', Response::MODEL_DEPLOYMENT_L Response::setModel(new BaseList('Executions List', Response::MODEL_EXECUTION_LIST, 'executions', Response::MODEL_EXECUTION)); Response::setModel(new BaseList('Projects List', Response::MODEL_PROJECT_LIST, 'projects', Response::MODEL_PROJECT, true, false)); Response::setModel(new BaseList('Webhooks List', Response::MODEL_WEBHOOK_LIST, 'webhooks', Response::MODEL_WEBHOOK, true, false)); -Response::setModel(new BaseList('API Keys List', Response::MODEL_KEY_LIST, 'keys', Response::MODEL_KEY, true, false)); +Response::setModel(new BaseList('API Keys List', Response::MODEL_KEY_LIST, 'keys', Response::MODEL_KEY, true, true)); Response::setModel(new BaseList('Dev Keys List', Response::MODEL_DEV_KEY_LIST, 'devKeys', Response::MODEL_DEV_KEY, true, false)); Response::setModel(new BaseList('Auth Providers List', Response::MODEL_AUTH_PROVIDER_LIST, 'platforms', Response::MODEL_AUTH_PROVIDER, true, false)); Response::setModel(new BaseList('Platforms List', Response::MODEL_PLATFORM_LIST, 'platforms', Response::MODEL_PLATFORM, true, false)); From 497e5f8d0036adf056038868dbe637c0c89e0c39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sat, 10 Jan 2026 16:57:23 +0100 Subject: [PATCH 32/39] tests fixes --- app/config/collections/platform.php | 24 ++++++++++++++++++++++++ app/controllers/api/teams.php | 6 +++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/app/config/collections/platform.php b/app/config/collections/platform.php index 2fb3168c5b..39960f37b3 100644 --- a/app/config/collections/platform.php +++ b/app/config/collections/platform.php @@ -632,6 +632,30 @@ $platformCollections = [ '$id' => ID::custom('keys'), 'name' => 'keys', 'attributes' => [ + // Delete eventuelly, when removing dual-write too + [ + '$id' => ID::custom('projectInternalId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + // Delete eventuelly, when removing dual-write too + [ + '$id' => ID::custom('projectId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => false, + 'default' => 0, + 'array' => false, + 'filters' => [], + ], [ '$id' => ID::custom('resourceType'), 'type' => Database::VAR_STRING, diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index 9bf4a75d83..f151194e07 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -433,19 +433,19 @@ App::delete('/v1/teams/:teamId') // Sync delete $deletes = new Deletes(); - $deletes->deleteMemberships($getProjectDB, $clone, $project); + $deletes->deleteMemberships($getProjectDB, $team, $project); // Async delete if ($project->getId() === 'console') { $queueForDeletes ->setType(DELETE_TYPE_TEAM_PROJECTS) - ->setDocument($clone) + ->setDocument($team) ->trigger(); } $queueForDeletes ->setType(DELETE_TYPE_DOCUMENT) - ->setDocument($clone); + ->setDocument($team); $queueForEvents ->setParam('teamId', $team->getId()) From cd0d6092299f5c3546a6bd17ebb2af9437039023 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sat, 10 Jan 2026 17:01:31 +0100 Subject: [PATCH 33/39] quality improv --- app/config/errors.php | 5 +++++ app/init/resources.php | 7 ++----- src/Appwrite/Extend/Exception.php | 1 + 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/config/errors.php b/app/config/errors.php index e01d9064bf..50ba6b21e1 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -1074,6 +1074,11 @@ return [ 'description' => 'The project key has expired. Please generate a new key using the Appwrite console.', 'code' => 401, ], + Exception::ACCOUNT_KEY_EXPIRED => [ + 'name' => Exception::ACCOUNT_KEY_EXPIRED, + 'description' => 'The account key has expired. Please generate a new key using the Appwrite console.', + 'code' => 401, + ], Exception::ROUTER_HOST_NOT_FOUND => [ 'name' => Exception::ROUTER_HOST_NOT_FOUND, 'description' => 'Host is not trusted. This could occur because you have not configured a custom domain. Add a custom domain to your project first and try again.', diff --git a/app/init/resources.php b/app/init/resources.php index e627e444a1..4a6b0571eb 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -451,15 +451,12 @@ App::setResource('user', function (string $mode, Document $project, Document $co ); if (!empty($key)) { - $expired = false; $expire = $key->getAttribute('expire'); if (!empty($expire) && $expire < DatabaseDateTime::formatTz(DatabaseDateTime::now())) { - $expired = true; + throw new Exception(Exception::ACCOUNT_KEY_EXPIRED); } - if (!$expired) { - $user = $accountKeyUser; - } + $user = $accountKeyUser; } } } diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index 33c0942b2d..754b84599a 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -290,6 +290,7 @@ class Exception extends \Exception public const string PROJECT_INVALID_FAILURE_URL = 'project_invalid_failure_url'; public const string PROJECT_RESERVED_PROJECT = 'project_reserved_project'; public const string PROJECT_KEY_EXPIRED = 'project_key_expired'; + public const string ACCOUNT_KEY_EXPIRED = 'account_key_expired'; public const string PROJECT_SMTP_CONFIG_INVALID = 'project_smtp_config_invalid'; From 5d5a14bd77e3379c698337c48473cc42615f2c46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 15 Jan 2026 16:16:09 +0100 Subject: [PATCH 34/39] PR review fixes --- app/config/errors.php | 22 +++++++++++++++++- app/controllers/shared/api.php | 19 +++++++--------- app/init/resources.php | 37 +++++++++++++++++++++++++++++-- src/Appwrite/Extend/Exception.php | 7 +++++- 4 files changed, 70 insertions(+), 15 deletions(-) diff --git a/app/config/errors.php b/app/config/errors.php index 50ba6b21e1..62affd8101 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -357,6 +357,11 @@ return [ 'description' => 'API key and session used in the same request. Use either `setSession` or `setKey`. Learn about which authentication method to use in the SSR docs: https://appwrite.io/docs/products/auth/server-side-rendering', 'code' => 403, ], + Exception::USER_JWT_AND_COOKIE_SET => [ + 'name' => Exception::USER_JWT_AND_COOKIE_SET, + 'description' => 'JWT and cookie used in the same request. Use either `setJWT` or `setCookie`. Learn about which authentication method to use in the SSR docs: https://appwrite.io/docs/products/auth/server-side-rendering', + 'code' => 403, + ], Exception::API_KEY_EXPIRED => [ 'name' => Exception::API_KEY_EXPIRED, 'description' => 'The dynamic API key has expired. Please don\'t use dynamic API keys for more than duration of the execution.', @@ -1076,7 +1081,7 @@ return [ ], Exception::ACCOUNT_KEY_EXPIRED => [ 'name' => Exception::ACCOUNT_KEY_EXPIRED, - 'description' => 'The account key has expired. Please generate a new key using the Appwrite console.', + 'description' => 'The account API key has expired. Please generate a new key using the Appwrite console.', 'code' => 401, ], Exception::ROUTER_HOST_NOT_FOUND => [ @@ -1333,4 +1338,19 @@ return [ 'description' => 'Target has an invalid provider type.', 'code' => 400, ], + Exception::USER_ID_MISSING => [ + 'name' => Exception::USER_ID_MISSING, + 'description' => 'When using account API key, make sure to pass x-appwrite-user header with your user ID.', + 'code' => 403, + ], + Exception::ORGANIZATION_ID_MISSING => [ + 'name' => Exception::ORGANIZATION_ID_MISSING, + 'description' => 'When using organization API key, make sure to pass x-appwrite-organization header with your organization ID.', + 'code' => 403, + ], + Exception::PROJECT_ID_MISSING => [ + 'name' => Exception::PROJECT_ID_MISSING, + 'description' => 'When using project API key, make sure to pass x-appwrite-project header with your project ID.', + 'code' => 403, + ], ]; diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 99bf3b7a50..73e04b2028 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -357,16 +357,6 @@ App::init() throw new Exception(Exception::USER_UNAUTHORIZED); } - $purgeResource = function () use ($apiKey, $dbForPlatform, $project, $user, $team) { - if (!empty($apiKey->getProjectId())) { - Authorization::skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); - } elseif (!empty($apiKey->getUserId())) { - Authorization::skip(fn () => $dbForPlatform->purgeCachedDocument('users', $user->getId())); - } elseif (!empty($apiKey->getTeamId())) { - Authorization::skip(fn () => $dbForPlatform->purgeCachedDocument('teams', $team->getId())); - } - }; - $updates = new Document(); $accessedAt = $dbKey->getAttribute('accessedAt', 0); @@ -391,7 +381,14 @@ App::init() if (!$updates->isEmpty()) { Authorization::skip(fn () => $dbForPlatform->updateDocument('keys', $dbKey->getId(), $updates)); - $purgeResource(); + + if (!empty($apiKey->getProjectId())) { + Authorization::skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + } elseif (!empty($apiKey->getUserId())) { + Authorization::skip(fn () => $dbForPlatform->purgeCachedDocument('users', $user->getId())); + } elseif (!empty($apiKey->getTeamId())) { + Authorization::skip(fn () => $dbForPlatform->purgeCachedDocument('teams', $team->getId())); + } } $queueForAudits->setUser($user); diff --git a/app/init/resources.php b/app/init/resources.php index 7df966f93d..ac6c068b27 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -339,7 +339,7 @@ App::setResource('user', function (string $mode, Document $project, Document $co * 5. Regardless of the results from steps 1-4, attempts to fetch the JWT token. * 6. If the JWT user has a valid session ID, updates the user variable with the user from `projectDB`, * overwriting the previous value. - * 7. If account key is passed, use user of the account key as long as user ID header matches too + * 7. If account API key is passed, use user of the account API key as long as user ID header matches too */ $authorization->setDefaultStatus(true); @@ -416,12 +416,17 @@ App::setResource('user', function (string $mode, Document $project, Document $co // } $authJWT = $request->getHeader('x-appwrite-jwt', ''); if (!empty($authJWT) && !$project->isEmpty()) { // JWT authentication + if (!$user->isEmpty()) { + throw new Exception(Exception::USER_JWT_AND_COOKIE_SET); + } + $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 3600, 0); try { $payload = $jwt->decode($authJWT); } catch (JWTException $error) { throw new Exception(Exception::USER_JWT_INVALID, 'Failed to verify JWT. ' . $error->getMessage()); } + $jwtUserId = $payload['userId'] ?? ''; if (!empty($jwtUserId)) { if ($mode === APP_MODE_ADMIN) { @@ -442,6 +447,10 @@ App::setResource('user', function (string $mode, Document $project, Document $co $accountKey = $request->getHeader('x-appwrite-key', ''); $accountKeyUserId = $request->getHeader('x-appwrite-user', ''); if (!empty($accountKeyUserId) && !empty($accountKey)) { + if (!$user->isEmpty()) { + throw new Exception(Exception::USER_API_KEY_AND_SESSION_SET); + } + $accountKeyUser = Authorization::skip(fn () => $dbForPlatform->getDocument('users', $accountKeyUserId)); if (!$accountKeyUser->isEmpty()) { $key = $accountKeyUser->find( @@ -1109,7 +1118,31 @@ App::setResource('apiKey', function (Request $request, Document $project, Docume return null; } - return Key::decode($project, $team, $user, $key); + $key = Key::decode($project, $team, $user, $key); + + $userHeader = $request->getHeader('x-appwrite-user'); + $organizationHeader = $request->getHeader('x-appwrite-organization'); + $projectHeader = $request->getHeader('x-appwrite-project'); + + if (!empty($key->getProjectId())) { + if (empty($projectHeader) || $projectHeader !== $key->getProjectId()) { + throw new Exception(Exception::PROJECT_ID_MISSING); + } + } + + if (!empty($key->getUserId())) { + if (empty($userHeader) || $userHeader !== $key->getUserId()) { + throw new Exception(Exception::USER_ID_MISSING); + } + } + + if (!empty($key->getTeamId())) { + if (empty($organizationHeader) || $organizationHeader !== $key->getTeamId()) { + throw new Exception(Exception::ORGANIZATION_ID_MISSING); + } + } + + return $key; }, ['request', 'project', 'team', 'user']); App::setResource('executor', fn () => new Executor()); diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index 754b84599a..df123323ca 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -107,7 +107,9 @@ class Exception extends \Exception public const string USER_DELETION_PROHIBITED = 'user_deletion_prohibited'; public const string USER_TARGET_NOT_FOUND = 'user_target_not_found'; public const string USER_TARGET_ALREADY_EXISTS = 'user_target_already_exists'; - public const string USER_API_KEY_AND_SESSION_SET = 'user_key_and_session_set'; + public const string USER_API_KEY_AND_SESSION_SET = 'user_api_key_and_session_set'; + public const string USER_JWT_AND_COOKIE_SET = 'user_jwt_and_cookie_set'; + public const string USER_ID_MISSING = 'user_id_missing'; public const string API_KEY_EXPIRED = 'api_key_expired'; @@ -119,6 +121,8 @@ class Exception extends \Exception public const string TEAM_INVITE_MISMATCH = 'team_invite_mismatch'; public const string TEAM_ALREADY_EXISTS = 'team_already_exists'; + public const string ORGANIZATION_ID_MISSING = 'organization_id_missing'; + /** Console */ public const string RESOURCE_ALREADY_EXISTS = 'resource_already_exists'; @@ -283,6 +287,7 @@ class Exception extends \Exception /** Projects */ public const string PROJECT_NOT_FOUND = 'project_not_found'; + public const string PROJECT_ID_MISSING = 'project_id_missing'; public const string PROJECT_PROVIDER_DISABLED = 'project_provider_disabled'; public const string PROJECT_PROVIDER_UNSUPPORTED = 'project_provider_unsupported'; public const string PROJECT_ALREADY_EXISTS = 'project_already_exists'; From 6ebc36d0f353053ce338c9f0bd8ac9a39b466b43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 21 Jan 2026 14:47:18 +0100 Subject: [PATCH 35/39] Fix merge conflict --- app/init/database/filters.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/init/database/filters.php b/app/init/database/filters.php index c9beb526e1..ce220392b6 100644 --- a/app/init/database/filters.php +++ b/app/init/database/filters.php @@ -441,7 +441,7 @@ Database::addFilter( return; }, function (mixed $value, Document $document, Database $database) { - return Authorization::skip(fn () => $database + return $database->getAuthorization()->skip(fn () => $database ->find('keys', [ Query::equal('resourceType', ['teams']), Query::equal('resourceInternalId', [$document->getSequence()]), @@ -456,7 +456,7 @@ Database::addFilter( return; }, function (mixed $value, Document $document, Database $database) { - return Authorization::skip(fn () => $database + return $database->getAuthorization()->skip(fn () => $database ->find('keys', [ Query::equal('resourceType', ['users']), Query::equal('resourceInternalId', [$document->getSequence()]), From 8e98c08a23e74738c2c391f2ef0e920082d65b71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 21 Jan 2026 16:05:43 +0100 Subject: [PATCH 36/39] Fix failing tests --- app/controllers/shared/api.php | 8 ++++---- app/init/resources.php | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index f679480ba5..2825ea3a74 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -234,14 +234,14 @@ App::init() } if (!$updates->isEmpty()) { - Authorization::skip(fn () => $dbForPlatform->updateDocument('keys', $dbKey->getId(), $updates)); + $dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->updateDocument('keys', $dbKey->getId(), $updates)); if (!empty($apiKey->getProjectId())) { - Authorization::skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + $dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); } elseif (!empty($apiKey->getUserId())) { - Authorization::skip(fn () => $dbForPlatform->purgeCachedDocument('users', $user->getId())); + $dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->purgeCachedDocument('users', $user->getId())); } elseif (!empty($apiKey->getTeamId())) { - Authorization::skip(fn () => $dbForPlatform->purgeCachedDocument('teams', $team->getId())); + $dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->purgeCachedDocument('teams', $team->getId())); } } diff --git a/app/init/resources.php b/app/init/resources.php index e08d83743e..46f6ae05a0 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -455,7 +455,7 @@ App::setResource('user', function (string $mode, Document $project, Document $co throw new Exception(Exception::USER_API_KEY_AND_SESSION_SET); } - $accountKeyUser = Authorization::skip(fn () => $dbForPlatform->getDocument('users', $accountKeyUserId)); + $accountKeyUser = $dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->getDocument('users', $accountKeyUserId)); if (!$accountKeyUser->isEmpty()) { $key = $accountKeyUser->find( key: 'secret', From b317f85fb6f4ff25b6b875233cf6928543a86673 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 21 Jan 2026 16:27:09 +0100 Subject: [PATCH 37/39] Fix depricated schema --- app/config/collections/platform.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/config/collections/platform.php b/app/config/collections/platform.php index 39960f37b3..73c9eea870 100644 --- a/app/config/collections/platform.php +++ b/app/config/collections/platform.php @@ -639,7 +639,7 @@ $platformCollections = [ 'format' => '', 'size' => Database::LENGTH_KEY, 'signed' => true, - 'required' => true, + 'required' => false, 'default' => null, 'array' => false, 'filters' => [], From 14a96a2b56a79c665de8bc4a9f5e75d9864fd02f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 28 Jan 2026 14:50:17 +0100 Subject: [PATCH 38/39] Remove unnessessary attributes --- app/config/collections/platform.php | 24 ------------------------ app/controllers/api/projects.php | 3 --- app/controllers/mock.php | 3 --- 3 files changed, 30 deletions(-) diff --git a/app/config/collections/platform.php b/app/config/collections/platform.php index 73c9eea870..2fb3168c5b 100644 --- a/app/config/collections/platform.php +++ b/app/config/collections/platform.php @@ -632,30 +632,6 @@ $platformCollections = [ '$id' => ID::custom('keys'), 'name' => 'keys', 'attributes' => [ - // Delete eventuelly, when removing dual-write too - [ - '$id' => ID::custom('projectInternalId'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - // Delete eventuelly, when removing dual-write too - [ - '$id' => ID::custom('projectId'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => false, - 'default' => 0, - 'array' => false, - 'filters' => [], - ], [ '$id' => ID::custom('resourceType'), 'type' => Database::VAR_STRING, diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 1e03c861d1..57ad3030d9 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -1502,9 +1502,6 @@ App::post('/v1/projects/:projectId/keys') Permission::update(Role::any()), Permission::delete(Role::any()), ], - // TODO: @hmacr Remove `projectInternalId` and `projectId` column writes before deleting the column. - 'projectInternalId' => $project->getSequence(), - 'projectId' => $project->getId(), 'resourceInternalId' => $project->getSequence(), 'resourceId' => $project->getId(), 'resourceType' => 'projects', diff --git a/app/controllers/mock.php b/app/controllers/mock.php index 16d6d72de7..42b300e410 100644 --- a/app/controllers/mock.php +++ b/app/controllers/mock.php @@ -200,9 +200,6 @@ App::post('/v1/mock/api-key-unprefixed') Permission::update(Role::any()), Permission::delete(Role::any()), ], - // TODO: @hmacr Remove `projectInternalId` and `projectId` column writes before deleting the column. - 'projectInternalId' => $project->getSequence(), - 'projectId' => $project->getId(), 'resourceInternalId' => $project->getSequence(), 'resourceId' => $project->getId(), 'resourceType' => 'projects', From e22e8d6a5fe617ed76d53d2d5565854c36addcac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 28 Jan 2026 14:55:13 +0100 Subject: [PATCH 39/39] Upgrade phpunit for vuln --- composer.lock | 80 +++++++++++++++++++++++++-------------------------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/composer.lock b/composer.lock index bd56277819..1c7e6c2a5b 100644 --- a/composer.lock +++ b/composer.lock @@ -2066,16 +2066,16 @@ }, { "name": "phpseclib/phpseclib", - "version": "3.0.48", + "version": "3.0.49", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "64065a5679c50acb886e82c07aa139b0f757bb89" + "reference": "6233a1e12584754e6b5daa69fe1289b47775c1b9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/64065a5679c50acb886e82c07aa139b0f757bb89", - "reference": "64065a5679c50acb886e82c07aa139b0f757bb89", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/6233a1e12584754e6b5daa69fe1289b47775c1b9", + "reference": "6233a1e12584754e6b5daa69fe1289b47775c1b9", "shasum": "" }, "require": { @@ -2156,7 +2156,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.48" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.49" }, "funding": [ { @@ -2172,7 +2172,7 @@ "type": "tidelift" } ], - "time": "2025-12-15T11:51:42+00:00" + "time": "2026-01-27T09:17:28+00:00" }, { "name": "psr/container", @@ -2735,16 +2735,16 @@ }, { "name": "symfony/http-client", - "version": "v7.4.4", + "version": "v7.4.5", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "d63c23357d74715a589454c141c843f0172bec6c" + "reference": "84bb634857a893cc146cceb467e31b3f02c5fe9f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/d63c23357d74715a589454c141c843f0172bec6c", - "reference": "d63c23357d74715a589454c141c843f0172bec6c", + "url": "https://api.github.com/repos/symfony/http-client/zipball/84bb634857a893cc146cceb467e31b3f02c5fe9f", + "reference": "84bb634857a893cc146cceb467e31b3f02c5fe9f", "shasum": "" }, "require": { @@ -2812,7 +2812,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.4.4" + "source": "https://github.com/symfony/http-client/tree/v7.4.5" }, "funding": [ { @@ -2832,7 +2832,7 @@ "type": "tidelift" } ], - "time": "2026-01-23T16:34:22+00:00" + "time": "2026-01-27T16:16:02+00:00" }, { "name": "symfony/http-client-contracts", @@ -5120,28 +5120,28 @@ }, { "name": "utopia-php/swoole", - "version": "1.0.0", + "version": "1.0.1", "source": { "type": "git", "url": "https://github.com/utopia-php/swoole.git", - "reference": "95a937acb393dbf95cccba239d55886e2848ab0b" + "reference": "c5ce710dfffc4df09bf3e7aea2d1e55c53e77a95" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/swoole/zipball/95a937acb393dbf95cccba239d55886e2848ab0b", - "reference": "95a937acb393dbf95cccba239d55886e2848ab0b", + "url": "https://api.github.com/repos/utopia-php/swoole/zipball/c5ce710dfffc4df09bf3e7aea2d1e55c53e77a95", + "reference": "c5ce710dfffc4df09bf3e7aea2d1e55c53e77a95", "shasum": "" }, "require": { - "ext-swoole": "*", - "php": ">=8.0", + "ext-swoole": "6.*", + "php": ">=8.1", "utopia-php/framework": "0.33.37" }, "require-dev": { "laravel/pint": "1.2.*", "phpstan/phpstan": "^1.10", "phpunit/phpunit": "^9.3", - "swoole/ide-helper": "5.0.2" + "swoole/ide-helper": "6.0.2" }, "type": "library", "autoload": { @@ -5165,9 +5165,9 @@ ], "support": { "issues": "https://github.com/utopia-php/swoole/issues", - "source": "https://github.com/utopia-php/swoole/tree/1.0.0" + "source": "https://github.com/utopia-php/swoole/tree/1.0.1" }, - "time": "2026-01-14T14:00:11+00:00" + "time": "2026-01-28T12:43:38+00:00" }, { "name": "utopia-php/system", @@ -6772,16 +6772,16 @@ }, { "name": "phpunit/phpunit", - "version": "9.6.32", + "version": "9.6.34", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "492ee10a8369a1c1ac390a3b46e0c846e384c5a4" + "reference": "b36f02317466907a230d3aa1d34467041271ef4a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/492ee10a8369a1c1ac390a3b46e0c846e384c5a4", - "reference": "492ee10a8369a1c1ac390a3b46e0c846e384c5a4", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b36f02317466907a230d3aa1d34467041271ef4a", + "reference": "b36f02317466907a230d3aa1d34467041271ef4a", "shasum": "" }, "require": { @@ -6855,7 +6855,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.32" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.34" }, "funding": [ { @@ -6879,7 +6879,7 @@ "type": "tidelift" } ], - "time": "2026-01-24T16:04:20+00:00" + "time": "2026-01-27T05:45:00+00:00" }, { "name": "psr/cache", @@ -8199,16 +8199,16 @@ }, { "name": "symfony/finder", - "version": "v8.0.4", + "version": "v8.0.5", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "42e48eb02e07d5f3771d194d67da117eb824c8c1" + "reference": "8bd576e97c67d45941365bf824e18dc8538e6eb0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/42e48eb02e07d5f3771d194d67da117eb824c8c1", - "reference": "42e48eb02e07d5f3771d194d67da117eb824c8c1", + "url": "https://api.github.com/repos/symfony/finder/zipball/8bd576e97c67d45941365bf824e18dc8538e6eb0", + "reference": "8bd576e97c67d45941365bf824e18dc8538e6eb0", "shasum": "" }, "require": { @@ -8243,7 +8243,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v8.0.4" + "source": "https://github.com/symfony/finder/tree/v8.0.5" }, "funding": [ { @@ -8263,7 +8263,7 @@ "type": "tidelift" } ], - "time": "2026-01-12T12:37:40+00:00" + "time": "2026-01-26T15:08:38+00:00" }, { "name": "symfony/options-resolver", @@ -8668,16 +8668,16 @@ }, { "name": "symfony/process", - "version": "v8.0.4", + "version": "v8.0.5", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "10df72602d88c0a3fa685b822976a052611dd607" + "reference": "b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/10df72602d88c0a3fa685b822976a052611dd607", - "reference": "10df72602d88c0a3fa685b822976a052611dd607", + "url": "https://api.github.com/repos/symfony/process/zipball/b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674", + "reference": "b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674", "shasum": "" }, "require": { @@ -8709,7 +8709,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v8.0.4" + "source": "https://github.com/symfony/process/tree/v8.0.5" }, "funding": [ { @@ -8729,7 +8729,7 @@ "type": "tidelift" } ], - "time": "2026-01-23T11:07:10+00:00" + "time": "2026-01-26T15:08:38+00:00" }, { "name": "symfony/string", @@ -9075,5 +9075,5 @@ "platform-overrides": { "php": "8.3" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" }