Merge pull request #10988 from appwrite/chore-php-types

Feat: CE support for Platform API
This commit is contained in:
Matej Bačo 2026-01-28 14:45:42 +01:00 committed by GitHub
commit 273364114a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 705 additions and 68 deletions

View file

@ -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,
];

View file

@ -632,17 +632,19 @@ $platformCollections = [
'$id' => ID::custom('keys'),
'name' => 'keys',
'attributes' => [
// Delete eventuelly, when removing dual-write too
[
'$id' => ID::custom('projectInternalId'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => Database::LENGTH_KEY,
'signed' => true,
'required' => true,
'required' => false,
'default' => null,
'array' => false,
'filters' => [],
],
// Delete eventuelly, when removing dual-write too
[
'$id' => ID::custom('projectId'),
'type' => Database::VAR_STRING,
@ -655,7 +657,7 @@ $platformCollections = [
'filters' => [],
],
[
'$id' => 'resourceType',
'$id' => ID::custom('resourceType'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => Database::LENGTH_KEY,
@ -666,7 +668,7 @@ $platformCollections = [
'filters' => [],
],
[
'$id' => 'resourceId',
'$id' => ID::custom('resourceId'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => Database::LENGTH_KEY,
@ -677,7 +679,7 @@ $platformCollections = [
'filters' => [],
],
[
'$id' => 'resourceInternalId',
'$id' => ID::custom('resourceInternalId'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => Database::LENGTH_KEY,
@ -756,21 +758,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' => [],

View file

@ -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,
],
];

View file

@ -0,0 +1,15 @@
<?php
// List of scopes for Account API keys (Tokens)
return [
"account" => [
"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.',
],
];

View file

@ -0,0 +1,42 @@
<?php
// List of scopes for organization (teams) API keys
return [
"platforms.read" => [
"description" => 'Access to read project\'s platforms',
],
"platforms.write" => [
"description" =>
'Access to create, update, and delete project\'s platforms',
],
"projects.read" => [
"description" => 'Access to read organization\'s projects',
],
"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",
],
];

View file

@ -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')
@ -1628,7 +1628,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 +1729,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')

View file

@ -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))

View file

@ -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';
}

View file

@ -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(),

View file

@ -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()));
}
}

View file

@ -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

View file

@ -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';

View file

@ -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),
]));
}
);

View file

@ -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));

View file

@ -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());

View file

@ -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

View file

@ -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;
}

View file

@ -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';

View file

@ -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)

View file

@ -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)

View file

@ -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) {

View file

@ -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

View file

@ -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()
{

View file

@ -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
*/

View file

@ -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;
}