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, ]; diff --git a/app/config/collections/platform.php b/app/config/collections/platform.php index 8e214e7999..2fb3168c5b 100644 --- a/app/config/collections/platform.php +++ b/app/config/collections/platform.php @@ -633,29 +633,7 @@ $platformCollections = [ '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'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => false, - 'default' => 0, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => 'resourceType', + '$id' => ID::custom('resourceType'), 'type' => Database::VAR_STRING, 'format' => '', 'size' => Database::LENGTH_KEY, @@ -666,7 +644,7 @@ $platformCollections = [ 'filters' => [], ], [ - '$id' => 'resourceId', + '$id' => ID::custom('resourceId'), 'type' => Database::VAR_STRING, 'format' => '', 'size' => Database::LENGTH_KEY, @@ -677,7 +655,7 @@ $platformCollections = [ 'filters' => [], ], [ - '$id' => 'resourceInternalId', + '$id' => ID::custom('resourceInternalId'), 'type' => Database::VAR_STRING, 'format' => '', 'size' => Database::LENGTH_KEY, @@ -756,21 +734,14 @@ $platformCollections = [ ], 'indexes' => [ [ - '$id' => ID::custom('_key_project'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['projectInternalId'], - 'lengths' => [Database::LENGTH_KEY], - 'orders' => [Database::ORDER_ASC], - ], - [ - '$id' => '_key_resource', + '$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', + '$id' => ID::custom('_key_accessedAt'), 'type' => Database::INDEX_KEY, 'attributes' => ['accessedAt'], 'lengths' => [], diff --git a/app/config/errors.php b/app/config/errors.php index e01d9064bf..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.', @@ -1074,6 +1079,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 API 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.', @@ -1328,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/config/scopes/account.php b/app/config/scopes/account.php new file mode 100644 index 0000000000..7705dfca8a --- /dev/null +++ b/app/config/scopes/account.php @@ -0,0 +1,15 @@ + [ + "description" => 'Access to manage account, its 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 its 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 b0493ff38f..57ad3030d9 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -1483,7 +1483,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') @@ -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', @@ -1628,7 +1625,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') @@ -1729,7 +1726,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/api/teams.php b/app/controllers/api/teams.php index 2cee394a9c..703582f3fd 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -433,15 +433,22 @@ App::delete('/v1/teams/:teamId') throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove team from DB'); } + // Sync delete $deletes = new Deletes(); $deletes->deleteMemberships($getProjectDB, $team, $project); + // Async delete if ($project->getId() === 'console') { $queueForDeletes ->setType(DELETE_TYPE_TEAM_PROJECTS) - ->setDocument($team); + ->setDocument($team) + ->trigger(); } + $queueForDeletes + ->setType(DELETE_TYPE_DOCUMENT) + ->setDocument($team); + $queueForEvents ->setParam('teamId', $team->getId()) ->setPayload($response->output($team, Response::MODEL_TEAM)) diff --git a/app/controllers/general.php b/app/controllers/general.php index 8fc5a11503..eaa01495b9 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -1503,7 +1503,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/controllers/mock.php b/app/controllers/mock.php index fd7dae55b4..42b300e410 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(), @@ -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', diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index fffe544330..2825ea3a74 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -157,10 +157,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); @@ -189,23 +185,38 @@ 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 (\in_array($apiKey->getType(), [API_KEY_STANDARD, API_KEY_ORGANIZATION, API_KEY_ACCOUNT])) { + $dbKey = null; + 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); } + $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); @@ -216,12 +227,21 @@ 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->getAuthorization()->skip(fn () => $dbForPlatform->updateDocument('keys', $dbKey->getId(), $updates)); + + if (!empty($apiKey->getProjectId())) { + $dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->purgeCachedDocument('projects', $project->getId())); + } elseif (!empty($apiKey->getUserId())) { + $dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->purgeCachedDocument('users', $user->getId())); + } elseif (!empty($apiKey->getTeamId())) { + $dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->purgeCachedDocument('teams', $team->getId())); } } 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 e05f31e078..77532f8306 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -194,6 +194,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_EXECUTIONS_LIMIT = 'executionsLimit'; @@ -249,6 +250,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/database/filters.php b/app/init/database/filters.php index 2b2e17b6a9..ce220392b6 100644 --- a/app/init/database/filters.php +++ b/app/init/database/filters.php @@ -433,3 +433,34 @@ Database::addFilter( return $value; } ); + + +Database::addFilter( + 'subQueryOrganizationKeys', + function (mixed $value) { + return; + }, + function (mixed $value, Document $document, Database $database) { + return $database->getAuthorization()->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 $database->getAuthorization()->skip(fn () => $database + ->find('keys', [ + Query::equal('resourceType', ['users']), + Query::equal('resourceInternalId', [$document->getSequence()]), + Query::limit(APP_LIMIT_SUBQUERY), + ])); + } +); 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)); diff --git a/app/init/resources.php b/app/init/resources.php index fc0e0152d0..46f6ae05a0 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -343,6 +343,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 API key is passed, use user of the account API key as long as user ID header matches too */ $authorization->setDefaultStatus(true); @@ -419,12 +420,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) { @@ -440,6 +446,34 @@ App::setResource('user', function (string $mode, Document $project, Document $co } } } + + // Account based on account API key + $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 = $dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->getDocument('users', $accountKeyUserId)); + if (!$accountKeyUser->isEmpty()) { + $key = $accountKeyUser->find( + key: 'secret', + find: $accountKey, + subject: 'keys' + ); + + if (!empty($key)) { + $expire = $key->getAttribute('expire'); + if (!empty($expire) && $expire < DatabaseDateTime::formatTz(DatabaseDateTime::now())) { + throw new Exception(Exception::ACCOUNT_KEY_EXPIRED); + } + + $user = $accountKeyUser; + } + } + } + $dbForProject->setMetadata('user', $user->getId()); $dbForPlatform->setMetadata('user', $user->getId()); @@ -1227,7 +1261,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]; @@ -1282,15 +1316,39 @@ 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']); + $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/composer.lock b/composer.lock index 662623c701..fa87eebe72 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", @@ -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" } diff --git a/docker-compose.yml b/docker-compose.yml index 0de7cc73c6..b32c41f802 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 diff --git a/src/Appwrite/Auth/Key.php b/src/Appwrite/Auth/Key.php index b1f3836fb6..8f645f6f08 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, @@ -34,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; @@ -95,13 +107,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, + Document $team, + Document $user, string $key ): Key { if (\str_contains($key, '_')) { @@ -118,6 +129,8 @@ class Key $guestKey = new Key( $project->getId(), + '', + '', $type, User::ROLE_GUESTS, $roles[User::ROLE_GUESTS]['scopes'] ?? [], @@ -133,6 +146,7 @@ class Key leeway: 0 ); + $payload = []; try { $payload = $jwtObj->decode($secret); } catch (JWTException) { @@ -155,6 +169,8 @@ class Key return new Key( $projectId, + '', + '', $type, $role, $scopes, @@ -188,12 +204,86 @@ 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; + + $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; + + $scopes = $key->getAttribute('scopes', []); + + $key = new Key( + '', + $team->getId(), + '', + $type, + $role, + $scopes, + $name, + $expired + ); + + return $key; default: return $guestKey; } diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index 33c0942b2d..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'; @@ -290,6 +295,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'; diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php index 79c6afb92b..4bf072d115 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 f73e7ed8b8..e5ff11864a 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) { diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index 654b083a98..b53871f7b4 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -125,6 +125,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; @@ -530,7 +533,7 @@ class Deletes extends Action } /** - * @var $dbForProject Database + * @var Database $dbForProject */ $dbForProject = $getProjectDB($document); @@ -661,6 +664,24 @@ class Deletes extends Action } } + 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 @@ -680,6 +701,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 diff --git a/src/Appwrite/Utopia/Response/Model/Key.php b/src/Appwrite/Utopia/Response/Model/Key.php index 1adab4417b..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 = false; + 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 e31331574f..d5ceb3499f 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1765,12 +1765,13 @@ class ProjectsConsoleClientTest extends Scope return $data; } - /** - * @depends testUpdateProjectAuthLimit - */ - public function testUpdateProjectAuthSessionsLimit($data): array + public function testUpdateProjectAuthSessionsLimit(): void { - $id = $data['projectId'] ?? ''; + $id = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'testUpdateProjectAuthSessionsLimit', + 'region' => System::getEnv('_APP_REGION', 'default') + ]); /** * Test for failure @@ -1881,10 +1882,9 @@ class ProjectsConsoleClientTest extends Scope 'limit' => 10, ]); - return $data; + $this->assertEquals(200, $response['headers']['status-code']); } - /** * @depends testUpdateProjectAuthLimit */ diff --git a/tests/unit/Auth/KeyTest.php b/tests/unit/Auth/KeyTest.php index 920608e82f..fc1779efad 100644 --- a/tests/unit/Auth/KeyTest.php +++ b/tests/unit/Auth/KeyTest.php @@ -14,6 +14,7 @@ class KeyTest extends TestCase { public function testDecode(): void { + // Decode dynamic key $projectId = 'test'; $usage = false; $scopes = [ @@ -22,34 +23,345 @@ 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); - $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('', $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()); + + // Decode dynamic 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']; + $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()); + $this->assertEquals('Standard key', $decoded->getName()); + + // Decode deprecated 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()); + $this->assertEquals('Standard key', $decoded->getName()); + + // 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()); + $this->assertEquals('UNKNOWN', $decoded->getName()); + + // 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()); + $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 + $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; }