From 36ce992e9fd1ffac7a48c3cd9c0ab97926e5d27b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 29 Jan 2024 11:15:07 +0000 Subject: [PATCH 01/11] Add API Tokens --- app/config/collections.php | 13 ++++++- app/controllers/api/functions.php | 31 +++++++++++++---- app/controllers/general.php | 38 +++++++++++++++++++-- docker-compose.yml | 2 ++ src/Appwrite/Platform/Workers/Functions.php | 14 ++++++++ 5 files changed, 88 insertions(+), 10 deletions(-) diff --git a/app/config/collections.php b/app/config/collections.php index e2f3c11be..3581b3c1f 100644 --- a/app/config/collections.php +++ b/app/config/collections.php @@ -2099,7 +2099,18 @@ $projectCollections = array_merge([ 'required' => false, 'default' => null, 'filters' => [], - ] + ], + [ + '$id' => ID::custom('apiTokenScopes'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => true, + 'filters' => [], + ], ], 'indexes' => [ [ diff --git a/app/controllers/api/functions.php b/app/controllers/api/functions.php index d3913d180..2fb7f73bc 100644 --- a/app/controllers/api/functions.php +++ b/app/controllers/api/functions.php @@ -150,6 +150,7 @@ App::post('/v1/functions') ->param('logging', true, new Boolean(), 'Whether executions will be logged. When set to false, executions will not be logged, but will reduce resource used by your Appwrite project.', 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('apiTokenScopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('scopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of scopes allowed for API Token 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) @@ -168,7 +169,7 @@ App::post('/v1/functions') ->inject('queueForBuilds') ->inject('dbForConsole') ->inject('gitHub') - ->action(function (string $functionId, string $name, string $runtime, array $execute, array $events, string $schedule, int $timeout, bool $enabled, bool $logging, string $entrypoint, string $commands, string $installationId, string $providerRepositoryId, string $providerBranch, bool $providerSilentMode, string $providerRootDirectory, string $templateRepository, string $templateOwner, string $templateRootDirectory, string $templateBranch, Request $request, Response $response, Database $dbForProject, Document $project, Document $user, Event $queueForEvents, Build $queueForBuilds, Database $dbForConsole, GitHub $github) use ($redeployVcs) { + ->action(function (string $functionId, string $name, string $runtime, array $execute, array $events, string $schedule, int $timeout, bool $enabled, bool $logging, string $entrypoint, string $commands, array $apiTokenScopes, string $installationId, string $providerRepositoryId, string $providerBranch, bool $providerSilentMode, string $providerRootDirectory, string $templateRepository, string $templateOwner, string $templateRootDirectory, string $templateBranch, Request $request, Response $response, Database $dbForProject, Document $project, Document $user, Event $queueForEvents, Build $queueForBuilds, Database $dbForConsole, GitHub $github) use ($redeployVcs) { $functionId = ($functionId == 'unique()') ? ID::unique() : $functionId; // build from template @@ -212,6 +213,7 @@ App::post('/v1/functions') 'timeout' => $timeout, 'entrypoint' => $entrypoint, 'commands' => $commands, + 'apiTokenScopes' => $apiTokenScopes, 'search' => implode(' ', [$functionId, $name, $runtime]), 'version' => 'v3', 'installationId' => $installation->getId(), @@ -663,6 +665,7 @@ App::put('/v1/functions/:functionId') ->param('logging', true, new Boolean(), 'Whether executions will be logged. When set to false, executions will not be logged, but will reduce resource used by your Appwrite project.', 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('apiTokenScopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('scopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of scopes allowed for API Token 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', '', 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) @@ -676,7 +679,7 @@ App::put('/v1/functions/:functionId') ->inject('queueForBuilds') ->inject('dbForConsole') ->inject('gitHub') - ->action(function (string $functionId, string $name, string $runtime, array $execute, array $events, string $schedule, int $timeout, bool $enabled, bool $logging, string $entrypoint, string $commands, string $installationId, string $providerRepositoryId, string $providerBranch, bool $providerSilentMode, string $providerRootDirectory, Request $request, Response $response, Database $dbForProject, Document $project, Event $queueForEvents, Build $queueForBuilds, Database $dbForConsole, GitHub $github) use ($redeployVcs) { + ->action(function (string $functionId, string $name, string $runtime, array $execute, array $events, string $schedule, int $timeout, bool $enabled, bool $logging, string $entrypoint, string $commands, array $apiTokenScopes, string $installationId, string $providerRepositoryId, string $providerBranch, bool $providerSilentMode, string $providerRootDirectory, Request $request, Response $response, Database $dbForProject, Document $project, Event $queueForEvents, Build $queueForBuilds, Database $dbForConsole, GitHub $github) use ($redeployVcs) { // TODO: If only branch changes, re-deploy $function = $dbForProject->getDocument('functions', $functionId); @@ -788,6 +791,7 @@ App::put('/v1/functions/:functionId') 'logging' => $logging, 'entrypoint' => $entrypoint, 'commands' => $commands, + 'apiTokenScopes' => $apiTokenScopes, 'installationId' => $installation->getId(), 'installationInternalId' => $installation->getInternalId(), 'providerRepositoryId' => $providerRepositoryId, @@ -1548,7 +1552,7 @@ App::post('/v1/functions/:functionId/executions') throw new Exception(Exception::USER_UNAUTHORIZED, $validator->getDescription()); } - $jwt = ''; // initialize + $userJwt = ''; // initialize if (!$user->isEmpty()) { // If userId exists, generate a JWT for function $sessions = $user->getAttribute('sessions', []); $current = new Document(); @@ -1561,17 +1565,25 @@ App::post('/v1/functions/:functionId/executions') } if (!$current->isEmpty()) { - $jwtObj = new JWT(App::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 900, 10); // Instantiate with key, algo, maxAge and leeway. - $jwt = $jwtObj->encode([ + $jwtObj = new JWT(App::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 900, 10); + $userJwt = $jwtObj->encode([ 'userId' => $user->getId(), 'sessionId' => $current->getId(), ]); } } + $jwtExpiry = $function->getAttribute('timeout', 900); + $jwtObj = new JWT(App::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 10); + $apiToken = $jwtObj->encode([ + 'projectId' => $project->getId(), + 'scopes' => $function->getAttribute('apiTokenScopes', []) + ]); + + $headers['x-appwrite-api-token'] = $apiToken; $headers['x-appwrite-trigger'] = 'http'; $headers['x-appwrite-user-id'] = $user->getId() ?? ''; - $headers['x-appwrite-user-jwt'] = $jwt ?? ''; + $headers['x-appwrite-user-jwt'] = $userJwt ?? ''; $headers['x-appwrite-country-code'] = ''; $headers['x-appwrite-continent-code'] = ''; $headers['x-appwrite-continent-eu'] = 'false'; @@ -1637,7 +1649,7 @@ App::post('/v1/functions/:functionId/executions') ->setHeaders($headers) ->setPath($path) ->setMethod($method) - ->setJWT($jwt) + ->setJWT($userJwt) ->setProject($project) ->setUser($user) ->setParam('functionId', $function->getId()) @@ -1673,8 +1685,13 @@ App::post('/v1/functions/:functionId/executions') $vars[$var->getAttribute('key')] = $var->getAttribute('value', ''); } + $protocol = App::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https'; + $hostname = App::getEnv('_APP_DOMAIN'); + $endpoint = $protocol . '://' . $hostname . "/v1"; + // Appwrite vars $vars = \array_merge($vars, [ + 'APPWRITE_FUNCTION_ENDPOINT' => $endpoint, 'APPWRITE_FUNCTION_ID' => $functionId, 'APPWRITE_FUNCTION_NAME' => $function->getAttribute('name'), 'APPWRITE_FUNCTION_DEPLOYMENT' => $deployment->getId(), diff --git a/app/controllers/general.php b/app/controllers/general.php index e443b96fc..eab9d8c6d 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -2,6 +2,8 @@ require_once __DIR__ . '/../init.php'; +use Ahc\Jwt\JWT; +use Ahc\Jwt\JWTException; use Utopia\App; use Utopia\Database\Helpers\Role; use Utopia\Locale\Locale; @@ -9,8 +11,6 @@ use Utopia\Logger\Logger; use Utopia\Logger\Log; use Utopia\Logger\Log\User; use Swoole\Http\Request as SwooleRequest; -use Utopia\Cache\Cache; -use Utopia\Pools\Group; use Appwrite\Utopia\Request; use Appwrite\Utopia\Response; use Appwrite\Utopia\View; @@ -531,6 +531,40 @@ App::init() } } + // API Token authentication (like API Key but JWT short-term) + $apiToken = $request->getHeader('x-appwrite-token', ''); + if (!empty($apiToken) && $user->isEmpty() && empty($authKey)) { + $jwtObj = new JWT(App::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 900, 10); + + try { + $payload = $jwtObj->decode($apiToken); + } catch (JWTException $error) { + // Ignore if token is invalid + } + + if (!empty($payload)) { + $projectId = $payload['projectId'] ?? ''; + $tokenScopes = $payload['scopes'] ?? []; + + // JWT includes project ID for better security + if ($projectId === $project->getId()) { + $user = new Document([ + '$id' => '', + 'status' => true, + 'email' => 'app.' . $project->getId() . '@service.' . $request->getHostname(), + 'password' => '', + 'name' => $project->getAttribute('name', 'Untitled'), + ]); + + $role = Auth::USER_ROLE_APPS; + $scopes = \array_merge($roles[$role]['scopes'], $tokenScopes); + + Authorization::setRole(Auth::USER_ROLE_APPS); + Authorization::setDefaultStatus(false); // Cancel security segmentation for API keys. + } + } + } + Authorization::setRole($role); foreach (Auth::getRoles($user) as $authRole) { diff --git a/docker-compose.yml b/docker-compose.yml index 5c645e3bc..2d6f5ba4e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -496,6 +496,8 @@ services: - _APP_ENV - _APP_WORKER_PER_CORE - _APP_OPENSSL_KEY_V1 + - _APP_DOMAIN + - _APP_OPTIONS_FORCE_HTTPS - _APP_REDIS_HOST - _APP_REDIS_PORT - _APP_REDIS_USER diff --git a/src/Appwrite/Platform/Workers/Functions.php b/src/Appwrite/Platform/Workers/Functions.php index bde5644ad..04dbc49de 100644 --- a/src/Appwrite/Platform/Workers/Functions.php +++ b/src/Appwrite/Platform/Workers/Functions.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Workers; +use Ahc\Jwt\JWT; use Appwrite\Event\Event; use Appwrite\Event\Func; use Appwrite\Event\Usage; @@ -275,6 +276,14 @@ class Functions extends Action $runtime = $runtimes[$function->getAttribute('runtime')]; + $jwtExpiry = $function->getAttribute('timeout', 900); + $jwtObj = new JWT(App::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 10); + $apiToken = $jwtObj->encode([ + 'projectId' => $project->getId(), + 'scopes' => $function->getAttribute('apiTokenScopes', []) + ]); + + $headers['x-appwrite-api-token'] = $apiToken; $headers['x-appwrite-trigger'] = $trigger; $headers['x-appwrite-event'] = $event ?? ''; $headers['x-appwrite-user-id'] = $user->getId() ?? ''; @@ -362,8 +371,13 @@ class Functions extends Action $vars[$var->getAttribute('key')] = $var->getAttribute('value', ''); } + $protocol = App::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https'; + $hostname = App::getEnv('_APP_DOMAIN'); + $endpoint = $protocol . '://' . $hostname . "/v1"; + // Appwrite vars $vars = \array_merge($vars, [ + 'APPWRITE_FUNCTION_ENDPOINT' => $endpoint, 'APPWRITE_FUNCTION_ID' => $functionId, 'APPWRITE_FUNCTION_NAME' => $function->getAttribute('name'), 'APPWRITE_FUNCTION_DEPLOYMENT' => $deploymentId, From ba6f5c1a57811f38d47bc2cc8252a4347e7f8036 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 29 Jan 2024 11:21:51 +0000 Subject: [PATCH 02/11] Revert unnessessary changes --- app/controllers/api/functions.php | 10 +++++----- app/controllers/general.php | 2 ++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/app/controllers/api/functions.php b/app/controllers/api/functions.php index 2fb7f73bc..ed3ad47b8 100644 --- a/app/controllers/api/functions.php +++ b/app/controllers/api/functions.php @@ -1552,7 +1552,7 @@ App::post('/v1/functions/:functionId/executions') throw new Exception(Exception::USER_UNAUTHORIZED, $validator->getDescription()); } - $userJwt = ''; // initialize + $jwt = ''; // initialize if (!$user->isEmpty()) { // If userId exists, generate a JWT for function $sessions = $user->getAttribute('sessions', []); $current = new Document(); @@ -1565,8 +1565,8 @@ App::post('/v1/functions/:functionId/executions') } if (!$current->isEmpty()) { - $jwtObj = new JWT(App::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 900, 10); - $userJwt = $jwtObj->encode([ + $jwtObj = new JWT(App::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 900, 10); // Instantiate with key, algo, maxAge and leeway. + $jwt = $jwtObj->encode([ 'userId' => $user->getId(), 'sessionId' => $current->getId(), ]); @@ -1583,7 +1583,7 @@ App::post('/v1/functions/:functionId/executions') $headers['x-appwrite-api-token'] = $apiToken; $headers['x-appwrite-trigger'] = 'http'; $headers['x-appwrite-user-id'] = $user->getId() ?? ''; - $headers['x-appwrite-user-jwt'] = $userJwt ?? ''; + $headers['x-appwrite-user-jwt'] = $jwt ?? ''; $headers['x-appwrite-country-code'] = ''; $headers['x-appwrite-continent-code'] = ''; $headers['x-appwrite-continent-eu'] = 'false'; @@ -1649,7 +1649,7 @@ App::post('/v1/functions/:functionId/executions') ->setHeaders($headers) ->setPath($path) ->setMethod($method) - ->setJWT($userJwt) + ->setJWT($jwt) ->setProject($project) ->setUser($user) ->setParam('functionId', $function->getId()) diff --git a/app/controllers/general.php b/app/controllers/general.php index eab9d8c6d..27a26d519 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -11,6 +11,8 @@ use Utopia\Logger\Logger; use Utopia\Logger\Log; use Utopia\Logger\Log\User; use Swoole\Http\Request as SwooleRequest; +use Utopia\Cache\Cache; +use Utopia\Pools\Group; use Appwrite\Utopia\Request; use Appwrite\Utopia\Response; use Appwrite\Utopia\View; From 448b0a56079ad3f7d8cfb4d3e0d853607af7eed2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 6 May 2024 08:35:27 +0000 Subject: [PATCH 03/11] Re-add api token auth --- app/controllers/shared/api.php | 36 ++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 1afd6b652..6c0183719 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -1,5 +1,7 @@ getHeader('x-appwrite-token', ''); + if (!empty($apiToken) && $user->isEmpty() && empty($authKey)) { + $jwtObj = new JWT(App::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 900, 10); + + try { + $payload = $jwtObj->decode($apiToken); + } catch (JWTException $error) { + // Ignore if token is invalid + } + + if (!empty($payload)) { + $projectId = $payload['projectId'] ?? ''; + $tokenScopes = $payload['scopes'] ?? []; + + // JWT includes project ID for better security + if ($projectId === $project->getId()) { + $user = new Document([ + '$id' => '', + 'status' => true, + 'email' => 'app.' . $project->getId() . '@service.' . $request->getHostname(), + 'password' => '', + 'name' => $project->getAttribute('name', 'Untitled'), + ]); + + $role = Auth::USER_ROLE_APPS; + $scopes = \array_merge($roles[$role]['scopes'], $tokenScopes); + + Authorization::setRole(Auth::USER_ROLE_APPS); + Authorization::setDefaultStatus(false); // Cancel security segmentation for API keys. + } + } + } + Authorization::setRole($role); foreach (Auth::getRoles($user) as $authRole) { From 3a3d5b61a6bc855d3a36dc9762a94967d73fc373 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 6 May 2024 09:55:59 +0000 Subject: [PATCH 04/11] PR review changes --- app/config/collections.php | 2 +- app/config/errors.php | 5 + app/controllers/api/functions.php | 18 ++-- app/controllers/shared/api.php | 111 ++++++++++---------- composer.lock | 22 ++-- src/Appwrite/Extend/Exception.php | 2 + src/Appwrite/Platform/Workers/Functions.php | 6 +- 7 files changed, 87 insertions(+), 79 deletions(-) diff --git a/app/config/collections.php b/app/config/collections.php index ae6e4d2e6..f8e6a78d8 100644 --- a/app/config/collections.php +++ b/app/config/collections.php @@ -3056,7 +3056,7 @@ $projectCollections = array_merge([ 'filters' => [], ], [ - '$id' => ID::custom('apiTokenScopes'), + '$id' => ID::custom('scopes'), 'type' => Database::VAR_STRING, 'format' => '', 'size' => Database::LENGTH_KEY, diff --git a/app/config/errors.php b/app/config/errors.php index 287e639f6..05409cf8c 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -332,6 +332,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::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.', + 'code' => 401, + ], /** Teams */ Exception::TEAM_NOT_FOUND => [ diff --git a/app/controllers/api/functions.php b/app/controllers/api/functions.php index b58c468b7..7f6594341 100644 --- a/app/controllers/api/functions.php +++ b/app/controllers/api/functions.php @@ -150,7 +150,7 @@ App::post('/v1/functions') ->param('logging', true, new Boolean(), 'Whether executions will be logged. When set to false, executions will not be logged, but will reduce resource used by your Appwrite project.', 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('apiTokenScopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('scopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of scopes allowed for API Token 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('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('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) @@ -169,7 +169,7 @@ App::post('/v1/functions') ->inject('queueForBuilds') ->inject('dbForConsole') ->inject('gitHub') - ->action(function (string $functionId, string $name, string $runtime, array $execute, array $events, string $schedule, int $timeout, bool $enabled, bool $logging, string $entrypoint, string $commands, array $apiTokenScopes, string $installationId, string $providerRepositoryId, string $providerBranch, bool $providerSilentMode, string $providerRootDirectory, string $templateRepository, string $templateOwner, string $templateRootDirectory, string $templateBranch, Request $request, Response $response, Database $dbForProject, Document $project, Document $user, Event $queueForEvents, Build $queueForBuilds, Database $dbForConsole, GitHub $github) use ($redeployVcs) { + ->action(function (string $functionId, string $name, string $runtime, array $execute, array $events, string $schedule, int $timeout, bool $enabled, bool $logging, string $entrypoint, string $commands, array $scopes, string $installationId, string $providerRepositoryId, string $providerBranch, bool $providerSilentMode, string $providerRootDirectory, string $templateRepository, string $templateOwner, string $templateRootDirectory, string $templateBranch, Request $request, Response $response, Database $dbForProject, Document $project, Document $user, Event $queueForEvents, Build $queueForBuilds, Database $dbForConsole, GitHub $github) use ($redeployVcs) { $functionId = ($functionId == 'unique()') ? ID::unique() : $functionId; $allowList = \array_filter(\explode(',', System::getEnv('_APP_FUNCTIONS_RUNTIMES', ''))); @@ -219,7 +219,7 @@ App::post('/v1/functions') 'timeout' => $timeout, 'entrypoint' => $entrypoint, 'commands' => $commands, - 'apiTokenScopes' => $apiTokenScopes, + 'scopes' => $scopes, 'search' => implode(' ', [$functionId, $name, $runtime]), 'version' => 'v3', 'installationId' => $installation->getId(), @@ -683,7 +683,7 @@ App::put('/v1/functions/:functionId') ->param('logging', true, new Boolean(), 'Whether executions will be logged. When set to false, executions will not be logged, but will reduce resource used by your Appwrite project.', 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('apiTokenScopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('scopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of scopes allowed for API Token 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('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('installationId', '', new Text(128, 0), 'Appwrite Installation ID for VCS (Version Controle 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) @@ -697,7 +697,7 @@ App::put('/v1/functions/:functionId') ->inject('queueForBuilds') ->inject('dbForConsole') ->inject('gitHub') - ->action(function (string $functionId, string $name, string $runtime, array $execute, array $events, string $schedule, int $timeout, bool $enabled, bool $logging, string $entrypoint, string $commands, array $apiTokenScopes, string $installationId, string $providerRepositoryId, string $providerBranch, bool $providerSilentMode, string $providerRootDirectory, Request $request, Response $response, Database $dbForProject, Document $project, Event $queueForEvents, Build $queueForBuilds, Database $dbForConsole, GitHub $github) use ($redeployVcs) { + ->action(function (string $functionId, string $name, string $runtime, array $execute, array $events, string $schedule, int $timeout, bool $enabled, bool $logging, string $entrypoint, string $commands, array $scopes, string $installationId, string $providerRepositoryId, string $providerBranch, bool $providerSilentMode, string $providerRootDirectory, Request $request, Response $response, Database $dbForProject, Document $project, Event $queueForEvents, Build $queueForBuilds, Database $dbForConsole, GitHub $github) use ($redeployVcs) { // TODO: If only branch changes, re-deploy $function = $dbForProject->getDocument('functions', $functionId); @@ -809,7 +809,7 @@ App::put('/v1/functions/:functionId') 'logging' => $logging, 'entrypoint' => $entrypoint, 'commands' => $commands, - 'apiTokenScopes' => $apiTokenScopes, + 'scopes' => $scopes, 'installationId' => $installation->getId(), 'installationInternalId' => $installation->getInternalId(), 'providerRepositoryId' => $providerRepositoryId, @@ -1595,12 +1595,12 @@ App::post('/v1/functions/:functionId/executions') $jwtExpiry = $function->getAttribute('timeout', 900); $jwtObj = new JWT(App::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 10); - $apiToken = $jwtObj->encode([ + $apiKey = $jwtObj->encode([ 'projectId' => $project->getId(), - 'scopes' => $function->getAttribute('apiTokenScopes', []) + 'scopes' => $function->getAttribute('scopes', []) ]); - $headers['x-appwrite-api-token'] = $apiToken; + $headers['x-appwrite-key'] = $apiKey; $headers['x-appwrite-trigger'] = 'http'; $headers['x-appwrite-user-id'] = $user->getId() ?? ''; $headers['x-appwrite-user-jwt'] = $jwt ?? ''; diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 6c0183719..a03661a7c 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -199,70 +199,24 @@ App::init() $authKey = $request->getHeader('x-appwrite-key', ''); - if (!empty($authKey)) { // API Key authentication + // API Key authentication + if (!empty($authKey)) { // Do not allow API key and session to be set at the same time if (!$user->isEmpty()) { throw new Exception(Exception::USER_API_KEY_AND_SESSION_SET); } - // Check if given key match project API keys - $key = $project->find('secret', $authKey, 'keys'); - if ($key) { - $user = new Document([ - '$id' => '', - 'status' => true, - 'email' => 'app.' . $project->getId() . '@service.' . $request->getHostname(), - 'password' => '', - 'name' => $project->getAttribute('name', 'Untitled'), - ]); + if(str_contains($authKey, '.')) { + // Dynamic key - $role = Auth::USER_ROLE_APPS; - $scopes = \array_merge($roles[$role]['scopes'], $key->getAttribute('scopes', [])); + $jwtObj = new JWT(App::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 900, 10); - $expire = $key->getAttribute('expire'); - if (!empty($expire) && $expire < DateTime::formatTz(DateTime::now())) { - throw new Exception(Exception::PROJECT_KEY_EXPIRED); + try { + $payload = $jwtObj->decode($authKey); + } catch (JWTException $error) { + throw new Exception(Exception::API_KEY_EXPIRED); } - Authorization::setRole(Auth::USER_ROLE_APPS); - Authorization::setDefaultStatus(false); // Cancel security segmentation for API keys. - - $accessedAt = $key->getAttribute('accessedAt', ''); - if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_KEY_ACCCESS)) > $accessedAt) { - $key->setAttribute('accessedAt', DateTime::now()); - $dbForConsole->updateDocument('keys', $key->getId(), $key); - $dbForConsole->purgeCachedDocument('projects', $project->getId()); - } - - $sdkValidator = new WhiteList($servers, true); - $sdk = $request->getHeader('x-sdk-name', 'UNKNOWN'); - if ($sdkValidator->isValid($sdk)) { - $sdks = $key->getAttribute('sdks', []); - if (!in_array($sdk, $sdks)) { - array_push($sdks, $sdk); - $key->setAttribute('sdks', $sdks); - - /** Update access time as well */ - $key->setAttribute('accessedAt', Datetime::now()); - $dbForConsole->updateDocument('keys', $key->getId(), $key); - $dbForConsole->purgeCachedDocument('projects', $project->getId()); - } - } - } - } - - // API Token authentication (like API Key but JWT short-term) - $apiToken = $request->getHeader('x-appwrite-token', ''); - if (!empty($apiToken) && $user->isEmpty() && empty($authKey)) { - $jwtObj = new JWT(App::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 900, 10); - - try { - $payload = $jwtObj->decode($apiToken); - } catch (JWTException $error) { - // Ignore if token is invalid - } - - if (!empty($payload)) { $projectId = $payload['projectId'] ?? ''; $tokenScopes = $payload['scopes'] ?? []; @@ -282,6 +236,53 @@ App::init() Authorization::setRole(Auth::USER_ROLE_APPS); Authorization::setDefaultStatus(false); // Cancel security segmentation for API keys. } + } else { + // Regular key + + // Check if given key match project API keys + $key = $project->find('secret', $authKey, 'keys'); + if ($key) { + $user = new Document([ + '$id' => '', + 'status' => true, + 'email' => 'app.' . $project->getId() . '@service.' . $request->getHostname(), + 'password' => '', + 'name' => $project->getAttribute('name', 'Untitled'), + ]); + + $role = Auth::USER_ROLE_APPS; + $scopes = \array_merge($roles[$role]['scopes'], $key->getAttribute('scopes', [])); + + $expire = $key->getAttribute('expire'); + if (!empty($expire) && $expire < DateTime::formatTz(DateTime::now())) { + throw new Exception(Exception::PROJECT_KEY_EXPIRED); + } + + Authorization::setRole(Auth::USER_ROLE_APPS); + Authorization::setDefaultStatus(false); // Cancel security segmentation for API keys. + + $accessedAt = $key->getAttribute('accessedAt', ''); + if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_KEY_ACCCESS)) > $accessedAt) { + $key->setAttribute('accessedAt', DateTime::now()); + $dbForConsole->updateDocument('keys', $key->getId(), $key); + $dbForConsole->purgeCachedDocument('projects', $project->getId()); + } + + $sdkValidator = new WhiteList($servers, true); + $sdk = $request->getHeader('x-sdk-name', 'UNKNOWN'); + if ($sdkValidator->isValid($sdk)) { + $sdks = $key->getAttribute('sdks', []); + if (!in_array($sdk, $sdks)) { + array_push($sdks, $sdk); + $key->setAttribute('sdks', $sdks); + + /** Update access time as well */ + $key->setAttribute('accessedAt', Datetime::now()); + $dbForConsole->updateDocument('keys', $key->getId(), $key); + $dbForConsole->purgeCachedDocument('projects', $project->getId()); + } + } + } } } diff --git a/composer.lock b/composer.lock index 34a3cc143..7c660bf17 100644 --- a/composer.lock +++ b/composer.lock @@ -1966,16 +1966,16 @@ }, { "name": "utopia-php/migration", - "version": "0.4.0", + "version": "0.4.1", "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "a72f27bd3dde68752fb185d306c4820e1b8d9657" + "reference": "ae3cfe93f6d313105d226aeb68806660c806a925" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/a72f27bd3dde68752fb185d306c4820e1b8d9657", - "reference": "a72f27bd3dde68752fb185d306c4820e1b8d9657", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/ae3cfe93f6d313105d226aeb68806660c806a925", + "reference": "ae3cfe93f6d313105d226aeb68806660c806a925", "shasum": "" }, "require": { @@ -2008,9 +2008,9 @@ ], "support": { "issues": "https://github.com/utopia-php/migration/issues", - "source": "https://github.com/utopia-php/migration/tree/0.4.0" + "source": "https://github.com/utopia-php/migration/tree/0.4.1" }, - "time": "2024-02-25T12:35:21+00:00" + "time": "2024-05-01T13:19:18+00:00" }, { "name": "utopia-php/mongo", @@ -2899,16 +2899,16 @@ }, { "name": "laravel/pint", - "version": "v1.15.2", + "version": "v1.15.3", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "2c9f8004899815f3f0ee3cb28ef7281e2b589134" + "reference": "3600b5d17aff52f6100ea4921849deacbbeb8656" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/2c9f8004899815f3f0ee3cb28ef7281e2b589134", - "reference": "2c9f8004899815f3f0ee3cb28ef7281e2b589134", + "url": "https://api.github.com/repos/laravel/pint/zipball/3600b5d17aff52f6100ea4921849deacbbeb8656", + "reference": "3600b5d17aff52f6100ea4921849deacbbeb8656", "shasum": "" }, "require": { @@ -2961,7 +2961,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2024-04-23T15:42:34+00:00" + "time": "2024-04-30T15:02:26+00:00" }, { "name": "matthiasmullie/minify", diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index 1c5692cd9..08fb89999 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -107,6 +107,8 @@ class Exception extends \Exception public const USER_TARGET_ALREADY_EXISTS = 'user_target_already_exists'; public const USER_API_KEY_AND_SESSION_SET = 'user_key_and_session_set'; + public const API_KEY_EXPIRED = 'api_key_expired'; + /** Teams */ public const TEAM_NOT_FOUND = 'team_not_found'; public const TEAM_INVITE_ALREADY_EXISTS = 'team_invite_already_exists'; diff --git a/src/Appwrite/Platform/Workers/Functions.php b/src/Appwrite/Platform/Workers/Functions.php index 4f4136ee1..93a74e9ed 100644 --- a/src/Appwrite/Platform/Workers/Functions.php +++ b/src/Appwrite/Platform/Workers/Functions.php @@ -285,12 +285,12 @@ class Functions extends Action $jwtExpiry = $function->getAttribute('timeout', 900); $jwtObj = new JWT(App::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 10); - $apiToken = $jwtObj->encode([ + $apiKey = $jwtObj->encode([ 'projectId' => $project->getId(), - 'scopes' => $function->getAttribute('apiTokenScopes', []) + 'scopes' => $function->getAttribute('scopes', []) ]); - $headers['x-appwrite-api-token'] = $apiToken; + $headers['x-appwrite-key'] = $apiKey; $headers['x-appwrite-trigger'] = $trigger; $headers['x-appwrite-event'] = $event ?? ''; $headers['x-appwrite-user-id'] = $user->getId() ?? ''; From 9252b66493bfd27538718e74926c6a689678c9f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 6 May 2024 11:27:28 +0000 Subject: [PATCH 05/11] Add scope tests --- .env | 2 +- app/controllers/api/functions.php | 6 +- app/controllers/shared/api.php | 2 +- docker-compose.yml | 1 + src/Appwrite/Platform/Workers/Functions.php | 6 +- src/Appwrite/Utopia/Response/Model/Func.php | 7 ++ .../Functions/FunctionsCustomServerTest.php | 97 ++++++++++++++++++- .../functions/php-scopes/composer.json | 18 ++++ .../resources/functions/php-scopes/index.php | 16 +++ 9 files changed, 142 insertions(+), 13 deletions(-) create mode 100644 tests/resources/functions/php-scopes/composer.json create mode 100644 tests/resources/functions/php-scopes/index.php diff --git a/.env b/.env index 09abb07be..b69303fff 100644 --- a/.env +++ b/.env @@ -15,7 +15,7 @@ _APP_OPTIONS_ROUTER_PROTECTION=disabled _APP_OPTIONS_FORCE_HTTPS=disabled _APP_OPTIONS_FUNCTIONS_FORCE_HTTPS=disabled _APP_OPENSSL_KEY_V1=your-secret-key -_APP_DOMAIN=localhost +_APP_DOMAIN=traefik _APP_DOMAIN_FUNCTIONS=functions.localhost _APP_DOMAIN_TARGET=localhost _APP_REDIS_HOST=redis diff --git a/app/controllers/api/functions.php b/app/controllers/api/functions.php index 7f6594341..be44315aa 100644 --- a/app/controllers/api/functions.php +++ b/app/controllers/api/functions.php @@ -1594,7 +1594,7 @@ App::post('/v1/functions/:functionId/executions') } $jwtExpiry = $function->getAttribute('timeout', 900); - $jwtObj = new JWT(App::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 10); + $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 10); $apiKey = $jwtObj->encode([ 'projectId' => $project->getId(), 'scopes' => $function->getAttribute('scopes', []) @@ -1705,8 +1705,8 @@ App::post('/v1/functions/:functionId/executions') $vars[$var->getAttribute('key')] = $var->getAttribute('value', ''); } - $protocol = App::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https'; - $hostname = App::getEnv('_APP_DOMAIN'); + $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https'; + $hostname = System::getEnv('_APP_DOMAIN'); $endpoint = $protocol . '://' . $hostname . "/v1"; // Appwrite vars diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index a03661a7c..60aa77e03 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -209,7 +209,7 @@ App::init() if(str_contains($authKey, '.')) { // Dynamic key - $jwtObj = new JWT(App::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 900, 10); + $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 900, 10); try { $payload = $jwtObj->decode($authKey); diff --git a/docker-compose.yml b/docker-compose.yml index 81ae4bacc..ab7b05f54 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,6 +42,7 @@ services: networks: - gateway - appwrite + - runtimes appwrite: container_name: appwrite diff --git a/src/Appwrite/Platform/Workers/Functions.php b/src/Appwrite/Platform/Workers/Functions.php index 93a74e9ed..da3fbb1a6 100644 --- a/src/Appwrite/Platform/Workers/Functions.php +++ b/src/Appwrite/Platform/Workers/Functions.php @@ -284,7 +284,7 @@ class Functions extends Action $runtime = $runtimes[$function->getAttribute('runtime')]; $jwtExpiry = $function->getAttribute('timeout', 900); - $jwtObj = new JWT(App::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 10); + $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 10); $apiKey = $jwtObj->encode([ 'projectId' => $project->getId(), 'scopes' => $function->getAttribute('scopes', []) @@ -378,8 +378,8 @@ class Functions extends Action $vars[$var->getAttribute('key')] = $var->getAttribute('value', ''); } - $protocol = App::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https'; - $hostname = App::getEnv('_APP_DOMAIN'); + $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https'; + $hostname = System::getEnv('_APP_DOMAIN'); $endpoint = $protocol . '://' . $hostname . "/v1"; // Appwrite vars diff --git a/src/Appwrite/Utopia/Response/Model/Func.php b/src/Appwrite/Utopia/Response/Model/Func.php index 0c8d4d42d..66c356e2b 100644 --- a/src/Appwrite/Utopia/Response/Model/Func.php +++ b/src/Appwrite/Utopia/Response/Model/Func.php @@ -71,6 +71,13 @@ class Func extends Model 'default' => '', 'example' => '5e5ea5c16897e', ]) + ->addRule('scopes', [ + 'type' => self::TYPE_STRING, + 'description' => 'Allowed permission scopes.', + 'default' => [], + 'example' => 'users.read', + 'array' => true, + ]) ->addRule('vars', [ 'type' => Response::MODEL_VARIABLE, 'description' => 'Function variables.', diff --git a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php index 1b9edc4f0..4f7116177 100644 --- a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php +++ b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php @@ -1023,7 +1023,7 @@ class FunctionsCustomServerTest extends Scope */ public function testCreateCustomExecution(string $folder, string $name, string $entrypoint, string $runtimeName, string $runtimeVersion) { - $timeout = 5; + $timeout = 15; $code = realpath(__DIR__ . '/../../../resources/functions') . "/$folder/code.tar.gz"; $this->packageCode($folder); @@ -1144,7 +1144,7 @@ class FunctionsCustomServerTest extends Scope public function testv2Function() { - $timeout = 5; + $timeout = 15; $code = realpath(__DIR__ . '/../../../resources/functions') . "/php-v2/code.tar.gz"; $this->packageCode('php-v2'); @@ -1264,7 +1264,7 @@ class FunctionsCustomServerTest extends Scope public function testEventTrigger() { - $timeout = 5; + $timeout = 15; $code = realpath(__DIR__ . '/../../../resources/functions') . "/php-event/code.tar.gz"; $this->packageCode('php-event'); @@ -1374,9 +1374,96 @@ class FunctionsCustomServerTest extends Scope $this->assertEquals(204, $response['headers']['status-code']); } + public function testScopes() + { + $timeout = 15; + $code = realpath(__DIR__ . '/../../../resources/functions') . "/php-scopes/code.tar.gz"; + $this->packageCode('php-scopes'); + + $function = $this->client->call(Client::METHOD_POST, '/functions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'functionId' => ID::unique(), + 'name' => 'Test PHP Scopes executions', + 'commands' => 'composer update --no-interaction --ignore-platform-reqs --optimize-autoloader --prefer-dist --no-dev', + 'runtime' => 'php-8.0', + 'entrypoint' => 'index.php', + 'scopes' => ['users.read'], + 'timeout' => $timeout, + ]); + + $functionId = $function['body']['$id'] ?? ''; + + $this->assertEquals(201, $function['headers']['status-code']); + + $deployment = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/deployments', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'entrypoint' => 'index.php', + 'code' => new CURLFile($code, 'application/x-gzip', basename($code)), + 'activate' => true + ]); + + $deploymentId = $deployment['body']['$id'] ?? ''; + $this->assertEquals(202, $deployment['headers']['status-code']); + + // Poll until deployment is built + while (true) { + $deployment = $this->client->call(Client::METHOD_GET, '/functions/' . $function['body']['$id'] . '/deployments/' . $deploymentId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + if ( + $deployment['headers']['status-code'] >= 400 + || \in_array($deployment['body']['status'], ['ready', 'failed']) + ) { + break; + } + + \sleep(1); + } + + $deployment = $this->client->call(Client::METHOD_PATCH, '/functions/' . $functionId . '/deployments/' . $deploymentId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), []); + + $this->assertEquals(200, $deployment['headers']['status-code']); + + // Wait a little for activation to finish + sleep(5); + + $execution = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/executions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'async' => false + ]); + + \var_dump($execution); + + $this->assertEquals(201, $execution['headers']['status-code']); + $this->assertEquals('completed', $execution['body']['status']); + $this->assertEquals(200, $execution['body']['responseStatusCode']); + $this->assertNotEmpty($execution['body']['responseBody']); + + // Cleanup : Delete function + $response = $this->client->call(Client::METHOD_DELETE, '/functions/' . $functionId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], []); + + $this->assertEquals(204, $response['headers']['status-code']); + } + public function testCookieExecution() { - $timeout = 5; + $timeout = 15; $code = realpath(__DIR__ . '/../../../resources/functions') . "/php-cookie/code.tar.gz"; $this->packageCode('php-cookie'); @@ -1464,7 +1551,7 @@ class FunctionsCustomServerTest extends Scope public function testFunctionsDomain() { - $timeout = 5; + $timeout = 15; $code = realpath(__DIR__ . '/../../../resources/functions') . "/php-cookie/code.tar.gz"; $this->packageCode('php-cookie'); diff --git a/tests/resources/functions/php-scopes/composer.json b/tests/resources/functions/php-scopes/composer.json new file mode 100644 index 000000000..8eacdab3e --- /dev/null +++ b/tests/resources/functions/php-scopes/composer.json @@ -0,0 +1,18 @@ +{ + "name": "appwrite/php-scopes", + "description": "PHP scopes test script", + "type": "library", + "license": "BSD-3-Clause", + "authors": [ + { + "name": "Team Appwrite", + "email": "team@appwrite.io" + } + ], + "require": { + "php": ">=7.4.0", + "ext-curl": "*", + "ext-json": "*", + "appwrite/appwrite": "11.0.*" + } +} diff --git a/tests/resources/functions/php-scopes/index.php b/tests/resources/functions/php-scopes/index.php new file mode 100644 index 000000000..d06fb7f76 --- /dev/null +++ b/tests/resources/functions/php-scopes/index.php @@ -0,0 +1,16 @@ +setEndpoint(getenv('APPWRITE_FUNCTION_ENDPOINT')) + ->setProject(getenv('APPWRITE_FUNCTION_PROJECT_ID')) + ->setKey($context->req->headers['x-appwrite-key']); + $users = new Users($client); + return $context->res->json($users->list()); +}; From 5002b0f3fa2a5042ecfdbd95d79359650a73c661 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 9 May 2024 11:50:45 +0000 Subject: [PATCH 06/11] PR review changes --- app/controllers/api/functions.php | 2 +- src/Appwrite/Platform/Workers/Functions.php | 2 +- tests/resources/functions/php-scopes/index.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/controllers/api/functions.php b/app/controllers/api/functions.php index be44315aa..c4d9aa1f1 100644 --- a/app/controllers/api/functions.php +++ b/app/controllers/api/functions.php @@ -1711,7 +1711,7 @@ App::post('/v1/functions/:functionId/executions') // Appwrite vars $vars = \array_merge($vars, [ - 'APPWRITE_FUNCTION_ENDPOINT' => $endpoint, + 'APPWRITE_FUNCTION_API_ENDPOINT' => $endpoint, 'APPWRITE_FUNCTION_ID' => $functionId, 'APPWRITE_FUNCTION_NAME' => $function->getAttribute('name'), 'APPWRITE_FUNCTION_DEPLOYMENT' => $deployment->getId(), diff --git a/src/Appwrite/Platform/Workers/Functions.php b/src/Appwrite/Platform/Workers/Functions.php index da3fbb1a6..9a1a96cf2 100644 --- a/src/Appwrite/Platform/Workers/Functions.php +++ b/src/Appwrite/Platform/Workers/Functions.php @@ -384,7 +384,7 @@ class Functions extends Action // Appwrite vars $vars = \array_merge($vars, [ - 'APPWRITE_FUNCTION_ENDPOINT' => $endpoint, + 'APPWRITE_FUNCTION_API_ENDPOINT' => $endpoint, 'APPWRITE_FUNCTION_ID' => $functionId, 'APPWRITE_FUNCTION_NAME' => $function->getAttribute('name'), 'APPWRITE_FUNCTION_DEPLOYMENT' => $deploymentId, diff --git a/tests/resources/functions/php-scopes/index.php b/tests/resources/functions/php-scopes/index.php index d06fb7f76..e05653b70 100644 --- a/tests/resources/functions/php-scopes/index.php +++ b/tests/resources/functions/php-scopes/index.php @@ -8,7 +8,7 @@ use Appwrite\Services\Users; return function ($context) { $client = new Client(); $client - ->setEndpoint(getenv('APPWRITE_FUNCTION_ENDPOINT')) + ->setEndpoint(getenv('APPWRITE_FUNCTION_API_ENDPOINT')) ->setProject(getenv('APPWRITE_FUNCTION_PROJECT_ID')) ->setKey($context->req->headers['x-appwrite-key']); $users = new Users($client); From 54c953d559161192d5315de735ae910f8b6d74af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 14 May 2024 11:58:31 +0000 Subject: [PATCH 07/11] Add prefix of api key types --- app/controllers/api/functions.php | 2 +- app/controllers/api/projects.php | 2 +- app/controllers/shared/api.php | 10 ++++++---- app/init.php | 3 +++ src/Appwrite/Platform/Workers/Functions.php | 2 +- 5 files changed, 12 insertions(+), 7 deletions(-) diff --git a/app/controllers/api/functions.php b/app/controllers/api/functions.php index c4d9aa1f1..670ca2f99 100644 --- a/app/controllers/api/functions.php +++ b/app/controllers/api/functions.php @@ -1600,7 +1600,7 @@ App::post('/v1/functions/:functionId/executions') 'scopes' => $function->getAttribute('scopes', []) ]); - $headers['x-appwrite-key'] = $apiKey; + $headers['x-appwrite-key'] = API_KEY_DYNAMIC . '_' . $apiKey; $headers['x-appwrite-trigger'] = 'http'; $headers['x-appwrite-user-id'] = $user->getId() ?? ''; $headers['x-appwrite-user-jwt'] = $jwt ?? ''; diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index cb75b76da..d81c37a61 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -1148,7 +1148,7 @@ App::post('/v1/projects/:projectId/keys') 'expire' => $expire, 'sdks' => [], 'accessedAt' => null, - 'secret' => \bin2hex(\random_bytes(128)), + 'secret' => API_KEY_STANDARD . '_' . \bin2hex(\random_bytes(128)), ]); $key = $dbForConsole->createDocument('keys', $key); diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 60aa77e03..4d0a65ff1 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -197,16 +197,18 @@ App::init() $scope = $route->getLabel('scope', 'none'); // Allowed scope for chosen route $scopes = $roles[$role]['scopes']; // Allowed scopes for user role - $authKey = $request->getHeader('x-appwrite-key', ''); + $apiKey = $request->getHeader('x-appwrite-key', ''); // API Key authentication - if (!empty($authKey)) { + if (!empty($apiKey)) { // Do not allow API key and session to be set at the same time if (!$user->isEmpty()) { throw new Exception(Exception::USER_API_KEY_AND_SESSION_SET); } - if(str_contains($authKey, '.')) { + [ $keyType, $authKey ] = \explode('_', $apiKey, 2); + + if($keyType === API_KEY_STANDARD) { // Dynamic key $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 900, 10); @@ -236,7 +238,7 @@ App::init() Authorization::setRole(Auth::USER_ROLE_APPS); Authorization::setDefaultStatus(false); // Cancel security segmentation for API keys. } - } else { + } elseif($keyType === API_KEY_DYNAMIC) { // Regular key // Check if given key match project API keys diff --git a/app/init.php b/app/init.php index f69bd323c..623c4bd14 100644 --- a/app/init.php +++ b/app/init.php @@ -208,6 +208,9 @@ const FUNCTION_ALLOWLIST_HEADERS_RESPONSE = ['content-type', 'content-length']; const MESSAGE_TYPE_EMAIL = 'email'; const MESSAGE_TYPE_SMS = 'sms'; const MESSAGE_TYPE_PUSH = 'push'; +// API key types +const API_KEY_STANDARD = 'standard'; +const API_KEY_DYNAMIC = 'dynamic'; // Usage metrics const METRIC_TEAMS = 'teams'; const METRIC_USERS = 'users'; diff --git a/src/Appwrite/Platform/Workers/Functions.php b/src/Appwrite/Platform/Workers/Functions.php index 9a1a96cf2..fc9a1242a 100644 --- a/src/Appwrite/Platform/Workers/Functions.php +++ b/src/Appwrite/Platform/Workers/Functions.php @@ -290,7 +290,7 @@ class Functions extends Action 'scopes' => $function->getAttribute('scopes', []) ]); - $headers['x-appwrite-key'] = $apiKey; + $headers['x-appwrite-key'] = API_KEY_DYNAMIC . '_' . $apiKey; $headers['x-appwrite-trigger'] = $trigger; $headers['x-appwrite-event'] = $event ?? ''; $headers['x-appwrite-user-id'] = $user->getId() ?? ''; From 4d249c5d36619524edee34ab57633768b376592c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 14 May 2024 12:27:35 +0000 Subject: [PATCH 08/11] Bug fix --- app/controllers/shared/api.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 4d0a65ff1..677037501 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -208,7 +208,7 @@ App::init() [ $keyType, $authKey ] = \explode('_', $apiKey, 2); - if($keyType === API_KEY_STANDARD) { + if($keyType === API_KEY_DYNAMIC) { // Dynamic key $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 900, 10); @@ -238,11 +238,11 @@ App::init() Authorization::setRole(Auth::USER_ROLE_APPS); Authorization::setDefaultStatus(false); // Cancel security segmentation for API keys. } - } elseif($keyType === API_KEY_DYNAMIC) { + } elseif($keyType === API_KEY_STANDARD) { // Regular key // Check if given key match project API keys - $key = $project->find('secret', $authKey, 'keys'); + $key = $project->find('secret', $apiKey, 'keys'); if ($key) { $user = new Document([ '$id' => '', From 37176e78657c8f592f83fe78a381b1f972b7de1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 14 May 2024 12:33:49 +0000 Subject: [PATCH 09/11] Backwards compatible approach --- app/controllers/api/projects.php | 2 +- app/controllers/shared/api.php | 2 +- src/Appwrite/Utopia/Response/Model/Key.php | 7 +++++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index d81c37a61..cb75b76da 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -1148,7 +1148,7 @@ App::post('/v1/projects/:projectId/keys') 'expire' => $expire, 'sdks' => [], 'accessedAt' => null, - 'secret' => API_KEY_STANDARD . '_' . \bin2hex(\random_bytes(128)), + 'secret' => \bin2hex(\random_bytes(128)), ]); $key = $dbForConsole->createDocument('keys', $key); diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 677037501..d08f208d8 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -242,7 +242,7 @@ App::init() // Regular key // Check if given key match project API keys - $key = $project->find('secret', $apiKey, 'keys'); + $key = $project->find('secret', $authKey, 'keys'); if ($key) { $user = new Document([ '$id' => '', diff --git a/src/Appwrite/Utopia/Response/Model/Key.php b/src/Appwrite/Utopia/Response/Model/Key.php index 1179a73d6..2dca87fdf 100644 --- a/src/Appwrite/Utopia/Response/Model/Key.php +++ b/src/Appwrite/Utopia/Response/Model/Key.php @@ -4,6 +4,7 @@ namespace Appwrite\Utopia\Response\Model; use Appwrite\Utopia\Response; use Appwrite\Utopia\Response\Model; +use Utopia\Database\Document; class Key extends Model { @@ -93,4 +94,10 @@ class Key extends Model { return Response::MODEL_KEY; } + + public function filter(Document $document): Document + { + $document->setAttribute('secret', API_KEY_STANDARD . '_' . $document->getAttribute('secret', '')); + return $document; + } } From 2e1f67245d7a7265efcbf3ba0cd6f7258fcba054 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 16 May 2024 08:39:15 +0000 Subject: [PATCH 10/11] Fix bugs, add old api key test --- app/controllers/api/projects.php | 2 +- app/controllers/mock.php | 50 +++++++++++++++++++ app/controllers/shared/api.php | 10 +++- src/Appwrite/Utopia/Response/Model/Key.php | 7 --- .../Databases/DatabasesCustomServerTest.php | 2 - .../Functions/FunctionsCustomServerTest.php | 2 - .../Projects/ProjectsConsoleClientTest.php | 39 +++++++++++++++ 7 files changed, 98 insertions(+), 14 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index cb75b76da..d81c37a61 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -1148,7 +1148,7 @@ App::post('/v1/projects/:projectId/keys') 'expire' => $expire, 'sdks' => [], 'accessedAt' => null, - 'secret' => \bin2hex(\random_bytes(128)), + 'secret' => API_KEY_STANDARD . '_' . \bin2hex(\random_bytes(128)), ]); $key = $dbForConsole->createDocument('keys', $key); diff --git a/app/controllers/mock.php b/app/controllers/mock.php index 6679fa14f..fdb1d80dc 100644 --- a/app/controllers/mock.php +++ b/app/controllers/mock.php @@ -6,6 +6,7 @@ use Appwrite\Extend\Exception; use Appwrite\Utopia\Request; use Appwrite\Utopia\Response; use Utopia\App; +use Utopia\Config\Config; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Helpers\ID; @@ -154,6 +155,55 @@ App::patch('/v1/mock/functions-v2') $response->noContent(); }); +App::post('/v1/mock/api-key-unprefixed') + ->desc('Create API Key (without standard prefix)') + ->groups(['mock', 'api', 'projects']) + ->label('scope', 'projects.write') + ->label('docs', false) + ->param('projectId', '', new UID(), 'Project ID.') + ->inject('response') + ->inject('dbForConsole') + ->action(function (string $projectId, Response $response, Database $dbForConsole) { + $isDevelopment = System::getEnv('_APP_ENV', 'development') === 'development'; + + if (!$isDevelopment) { + throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED); + } + + $project = $dbForConsole->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + $scopes = array_keys(Config::getParam('scopes')); + + $key = new Document([ + '$id' => ID::unique(), + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'projectInternalId' => $project->getInternalId(), + 'projectId' => $project->getId(), + 'name' => 'Outdated key', + 'scopes' => $scopes, + 'expire' => null, + 'sdks' => [], + 'accessedAt' => null, + 'secret' => \bin2hex(\random_bytes(128)), + ]); + + $key = $dbForConsole->createDocument('keys', $key); + + $dbForConsole->purgeCachedDocument('projects', $project->getId()); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($key, Response::MODEL_KEY); + }); + App::get('/v1/mock/github/callback') ->desc('Create installation document using GitHub installation id') ->groups(['mock', 'api', 'vcs']) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index d08f208d8..fc6792a91 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -206,7 +206,12 @@ App::init() throw new Exception(Exception::USER_API_KEY_AND_SESSION_SET); } - [ $keyType, $authKey ] = \explode('_', $apiKey, 2); + if(!\str_contains($apiKey, '_')) { + $keyType = API_KEY_STANDARD; + $authKey = $apiKey; + } else { + [ $keyType, $authKey ] = \explode('_', $apiKey, 2); + } if($keyType === API_KEY_DYNAMIC) { // Dynamic key @@ -239,10 +244,11 @@ App::init() Authorization::setDefaultStatus(false); // Cancel security segmentation for API keys. } } elseif($keyType === API_KEY_STANDARD) { + // No underline means no prefix. Backwards compatibility. // Regular key // Check if given key match project API keys - $key = $project->find('secret', $authKey, 'keys'); + $key = $project->find('secret', $apiKey, 'keys'); if ($key) { $user = new Document([ '$id' => '', diff --git a/src/Appwrite/Utopia/Response/Model/Key.php b/src/Appwrite/Utopia/Response/Model/Key.php index 2dca87fdf..1179a73d6 100644 --- a/src/Appwrite/Utopia/Response/Model/Key.php +++ b/src/Appwrite/Utopia/Response/Model/Key.php @@ -4,7 +4,6 @@ namespace Appwrite\Utopia\Response\Model; use Appwrite\Utopia\Response; use Appwrite\Utopia\Response\Model; -use Utopia\Database\Document; class Key extends Model { @@ -94,10 +93,4 @@ class Key extends Model { return Response::MODEL_KEY; } - - public function filter(Document $document): Document - { - $document->setAttribute('secret', API_KEY_STANDARD . '_' . $document->getAttribute('secret', '')); - return $document; - } } diff --git a/tests/e2e/Services/Databases/DatabasesCustomServerTest.php b/tests/e2e/Services/Databases/DatabasesCustomServerTest.php index 70bbedcb3..63fc3ca75 100644 --- a/tests/e2e/Services/Databases/DatabasesCustomServerTest.php +++ b/tests/e2e/Services/Databases/DatabasesCustomServerTest.php @@ -1296,8 +1296,6 @@ class DatabasesCustomServerTest extends Scope 'x-appwrite-key' => $this->getProject()['apiKey'], ], $this->getHeaders())); - \var_dump($attributes['body']); - $this->assertEquals(0, $attributes['body']['total']); } diff --git a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php index 4f7116177..6a8dc322e 100644 --- a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php +++ b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php @@ -1444,8 +1444,6 @@ class FunctionsCustomServerTest extends Scope 'async' => false ]); - \var_dump($execution); - $this->assertEquals(201, $execution['headers']['status-code']); $this->assertEquals('completed', $execution['body']['status']); $this->assertEquals(200, $execution['body']['responseStatusCode']); diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 6f60f01c7..b07ca1c8f 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -2704,6 +2704,45 @@ class ProjectsConsoleClientTest extends Scope return $data; } + /** + * @depends testCreateProject + */ + public function testCreateProjectKeyOutdated($data): void + { + $id = $data['projectId'] ?? ''; + + $response = $this->client->call(Client::METHOD_POST, '/mock/api-key-unprefixed', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'projectId' => $id + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertContains('users.read', $response['body']['scopes']); + $this->assertNotEmpty($response['body']['secret']); + $this->assertStringStartsNotWith(API_KEY_STANDARD . '_', $response['body']['secret']); + + $keyId = $response['body']['$id']; + $secret = $response['body']['secret']; + + $response = $this->client->call(Client::METHOD_GET, '/users', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-key' => $secret + ], []); + + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $id . '/keys/' . $keyId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), []); + + $this->assertEquals(204, $response['headers']['status-code']); + $this->assertEmpty($response['body']); + } + // Platforms /** From 0b3c188bbf32f995f00005b70d3a2efd303e6177 Mon Sep 17 00:00:00 2001 From: Khushboo Verma <43381712+vermakhushboo@users.noreply.github.com> Date: Tue, 28 May 2024 18:29:28 +0530 Subject: [PATCH 11/11] Add metrics for successful and failed builds --- app/init.php | 11 ++++++-- src/Appwrite/Platform/Workers/Builds.php | 33 +++++++++++++++++------- src/Appwrite/Platform/Workers/Usage.php | 32 +++++++++++++++++++++++ 3 files changed, 64 insertions(+), 12 deletions(-) diff --git a/app/init.php b/app/init.php index 623c4bd14..796620a10 100644 --- a/app/init.php +++ b/app/init.php @@ -231,11 +231,19 @@ const METRIC_FUNCTIONS = 'functions'; const METRIC_DEPLOYMENTS = 'deployments'; const METRIC_DEPLOYMENTS_STORAGE = 'deployments.storage'; const METRIC_BUILDS = 'builds'; +const METRIC_BUILDS_SUCCESS = 'builds.success'; +const METRIC_BUILDS_FAILED = 'builds.failed'; const METRIC_BUILDS_STORAGE = 'builds.storage'; const METRIC_BUILDS_COMPUTE = 'builds.compute'; +const METRIC_BUILDS_COMPUTE_SUCCESS = 'builds.compute.success'; +const METRIC_BUILDS_COMPUTE_FAILED = 'builds.compute.failed'; const METRIC_FUNCTION_ID_BUILDS = '{functionInternalId}.builds'; +const METRIC_FUNCTION_ID_BUILDS_SUCCESS = '{functionInternalId}.builds.success'; +const METRIC_FUNCTION_ID_BUILDS_FAILED = '{functionInternalId}.builds.failed'; const METRIC_FUNCTION_ID_BUILDS_STORAGE = '{functionInternalId}.builds.storage'; const METRIC_FUNCTION_ID_BUILDS_COMPUTE = '{functionInternalId}.builds.compute'; +const METRIC_FUNCTION_ID_BUILDS_COMPUTE_SUCCESS = '{functionInternalId}.builds.compute.success'; +const METRIC_FUNCTION_ID_BUILDS_COMPUTE_FAILED = '{functionInternalId}.builds.compute.failed'; const METRIC_FUNCTION_ID_DEPLOYMENTS = '{resourceType}.{resourceInternalId}.deployments'; const METRIC_FUNCTION_ID_DEPLOYMENTS_STORAGE = '{resourceType}.{resourceInternalId}.deployments.storage'; const METRIC_EXECUTIONS = 'executions'; @@ -343,8 +351,7 @@ Database::addFilter( if (isset($formatOptions['min']) || isset($formatOptions['max'])) { $attribute ->setAttribute('min', $formatOptions['min']) - ->setAttribute('max', $formatOptions['max']) - ; + ->setAttribute('max', $formatOptions['max']); } return $value; diff --git a/src/Appwrite/Platform/Workers/Builds.php b/src/Appwrite/Platform/Workers/Builds.php index ff1c439aa..7f43d776c 100644 --- a/src/Appwrite/Platform/Workers/Builds.php +++ b/src/Appwrite/Platform/Workers/Builds.php @@ -339,14 +339,13 @@ class Builds extends Action $deploymentModel = new Deployment(); $deploymentUpdate = $queueForEvents - ->setQueue(Event::WEBHOOK_QUEUE_NAME) - ->setClass(Event::WEBHOOK_CLASS_NAME) - ->setProject($project) - ->setEvent('functions.[functionId].deployments.[deploymentId].update') - ->setParam('functionId', $function->getId()) - ->setParam('deploymentId', $deployment->getId()) - ->setPayload($deployment->getArrayCopy(array_keys($deploymentModel->getRules()))) - ; + ->setQueue(Event::WEBHOOK_QUEUE_NAME) + ->setClass(Event::WEBHOOK_CLASS_NAME) + ->setProject($project) + ->setEvent('functions.[functionId].deployments.[deploymentId].update') + ->setParam('functionId', $function->getId()) + ->setParam('deploymentId', $deployment->getId()) + ->setPayload($deployment->getArrayCopy(array_keys($deploymentModel->getRules()))); $deploymentUpdate->trigger(); @@ -437,8 +436,8 @@ class Builds extends Action $build = $dbForProject->updateDocument('builds', $build->getId(), $build); /** - * Send realtime Event - */ + * Send realtime Event + */ $target = Realtime::fromPayload( // Pass first, most verbose event pattern event: $allEvents[0], @@ -534,6 +533,20 @@ class Builds extends Action ); /** Trigger usage queue */ + if ($build->getAttribute('status') === 'ready') { + $queueForUsage + ->addMetric(METRIC_BUILDS_SUCCESS, 1) // per project + ->addMetric(METRIC_BUILDS_COMPUTE_SUCCESS, (int)$build->getAttribute('duration', 0) * 1000) + ->addMetric(str_replace('{functionInternalId}', $function->getInternalId(), METRIC_FUNCTION_ID_BUILDS_SUCCESS), 1) // per function + ->addMetric(str_replace('{functionInternalId}', $function->getInternalId(), METRIC_FUNCTION_ID_BUILDS_COMPUTE_SUCCESS), (int)$build->getAttribute('duration', 0) * 1000); + } elseif ($build->getAttribute('status') === 'failed') { + $queueForUsage + ->addMetric(METRIC_BUILDS_FAILED, 1) // per project + ->addMetric(METRIC_BUILDS_COMPUTE_FAILED, (int)$build->getAttribute('duration', 0) * 1000) + ->addMetric(str_replace('{functionInternalId}', $function->getInternalId(), METRIC_FUNCTION_ID_BUILDS_FAILED), 1) // per function + ->addMetric(str_replace('{functionInternalId}', $function->getInternalId(), METRIC_FUNCTION_ID_BUILDS_COMPUTE_FAILED), (int)$build->getAttribute('duration', 0) * 1000); + } + $queueForUsage ->addMetric(METRIC_BUILDS, 1) // per project ->addMetric(METRIC_BUILDS_STORAGE, $build->getAttribute('size', 0)) diff --git a/src/Appwrite/Platform/Workers/Usage.php b/src/Appwrite/Platform/Workers/Usage.php index 48724c0d0..034e558d5 100644 --- a/src/Appwrite/Platform/Workers/Usage.php +++ b/src/Appwrite/Platform/Workers/Usage.php @@ -184,8 +184,12 @@ class Usage extends Action $deployments = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace(['{resourceType}', '{resourceInternalId}'], ['functions', $document->getInternalId()], METRIC_FUNCTION_ID_DEPLOYMENTS))); $deploymentsStorage = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace(['{resourceType}', '{resourceInternalId}'], ['functions', $document->getInternalId()], METRIC_FUNCTION_ID_DEPLOYMENTS_STORAGE))); $builds = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace('{functionInternalId}', $document->getInternalId(), METRIC_FUNCTION_ID_BUILDS))); + $buildsSuccess = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace('{functionInternalId}', $document->getInternalId(), METRIC_FUNCTION_ID_BUILDS_SUCCESS))); + $buildsFailed = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace('{functionInternalId}', $document->getInternalId(), METRIC_FUNCTION_ID_BUILDS_FAILED))); $buildsStorage = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace('{functionInternalId}', $document->getInternalId(), METRIC_FUNCTION_ID_BUILDS_STORAGE))); $buildsCompute = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace('{functionInternalId}', $document->getInternalId(), METRIC_FUNCTION_ID_BUILDS_COMPUTE))); + $buildsComputeSuccess = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace('{functionInternalId}', $document->getInternalId(), METRIC_FUNCTION_ID_BUILDS_COMPUTE_SUCCESS))); + $buildsComputeFailed = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace('{functionInternalId}', $document->getInternalId(), METRIC_FUNCTION_ID_BUILDS_COMPUTE_FAILED))); $executions = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace('{functionInternalId}', $document->getInternalId(), METRIC_FUNCTION_ID_EXECUTIONS))); $executionsCompute = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace('{functionInternalId}', $document->getInternalId(), METRIC_FUNCTION_ID_EXECUTIONS_COMPUTE))); @@ -210,6 +214,20 @@ class Usage extends Action ]; } + if (!empty($buildsSuccess['value'])) { + $metrics[] = [ + 'key' => METRIC_BUILDS_SUCCESS, + 'value' => ($buildsSuccess['value'] * -1), + ]; + } + + if (!empty($buildsFailed['value'])) { + $metrics[] = [ + 'key' => METRIC_BUILDS_FAILED, + 'value' => ($buildsFailed['value'] * -1), + ]; + } + if (!empty($buildsStorage['value'])) { $metrics[] = [ 'key' => METRIC_BUILDS_STORAGE, @@ -224,6 +242,20 @@ class Usage extends Action ]; } + if (!empty($buildsComputeSuccess['value'])) { + $metrics[] = [ + 'key' => METRIC_BUILDS_COMPUTE_SUCCESS, + 'value' => ($buildsComputeSuccess['value'] * -1), + ]; + } + + if (!empty($buildsComputeFailed['value'])) { + $metrics[] = [ + 'key' => METRIC_BUILDS_COMPUTE_FAILED, + 'value' => ($buildsComputeFailed['value'] * -1), + ]; + } + if (!empty($executions['value'])) { $metrics[] = [ 'key' => METRIC_EXECUTIONS,