diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index b0493ff38f..d9020fe823 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -6,7 +6,6 @@ use Appwrite\Event\Delete; use Appwrite\Event\Mail; use Appwrite\Event\Validator\Event; use Appwrite\Extend\Exception; -use Appwrite\Hooks\Hooks; use Appwrite\Network\Platform; use Appwrite\Network\Validator\Email; use Appwrite\SDK\AuthType; @@ -15,21 +14,12 @@ use Appwrite\SDK\Deprecated; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Template\Template; -use Appwrite\Utopia\Database\Validator\ProjectId; -use Appwrite\Utopia\Database\Validator\Queries\Projects; -use Appwrite\Utopia\Request; use Appwrite\Utopia\Response; use PHPMailer\PHPMailer\PHPMailer; use Utopia\App; -use Utopia\Audit\Adapter\Database as AdapterDatabase; -use Utopia\Audit\Audit; -use Utopia\Cache\Cache; use Utopia\Config\Config; -use Utopia\Database\Adapter\Pool as DatabasePool; use Utopia\Database\Database; -use Utopia\Database\DateTime; use Utopia\Database\Document; -use Utopia\Database\Exception\Duplicate; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; @@ -37,9 +27,7 @@ use Utopia\Database\Query; use Utopia\Database\Validator\Datetime as DatetimeValidator; use Utopia\Database\Validator\UID; use Utopia\Domains\Validator\PublicDomain; -use Utopia\DSN\DSN; use Utopia\Locale\Locale; -use Utopia\Pools\Group; use Utopia\System\System; use Utopia\Validator\ArrayList; use Utopia\Validator\Boolean; @@ -61,251 +49,6 @@ App::init() } }); -App::post('/v1/projects') - ->desc('Create project') - ->groups(['api', 'projects']) - ->label('audits.event', 'projects.create') - ->label('audits.resource', 'project/{response.$id}') - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'projects', - name: 'create', - description: '/docs/references/projects/create.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_CREATED, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', new ProjectId(), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, and hyphen. Can\'t start with a special char. Max length is 36 chars.') - ->param('name', null, new Text(128), 'Project name. Max length: 128 chars.') - ->param('teamId', '', new UID(), 'Team unique ID.') - ->param('region', System::getEnv('_APP_REGION', 'default'), new Whitelist(array_keys(array_filter(Config::getParam('regions'), fn ($config) => !$config['disabled']))), 'Project Region.', true) - ->param('description', '', new Text(256), 'Project description. Max length: 256 chars.', true) - ->param('logo', '', new Text(1024), 'Project logo.', true) - ->param('url', '', new URL(), 'Project URL.', true) - ->param('legalName', '', new Text(256), 'Project legal Name. Max length: 256 chars.', true) - ->param('legalCountry', '', new Text(256), 'Project legal Country. Max length: 256 chars.', true) - ->param('legalState', '', new Text(256), 'Project legal State. Max length: 256 chars.', true) - ->param('legalCity', '', new Text(256), 'Project legal City. Max length: 256 chars.', true) - ->param('legalAddress', '', new Text(256), 'Project legal Address. Max length: 256 chars.', true) - ->param('legalTaxId', '', new Text(256), 'Project legal Tax ID. Max length: 256 chars.', true) - ->inject('request') - ->inject('response') - ->inject('dbForPlatform') - ->inject('cache') - ->inject('pools') - ->inject('hooks') - ->action(function (string $projectId, string $name, string $teamId, string $region, string $description, string $logo, string $url, string $legalName, string $legalCountry, string $legalState, string $legalCity, string $legalAddress, string $legalTaxId, Request $request, Response $response, Database $dbForPlatform, Cache $cache, Group $pools, Hooks $hooks) { - - $team = $dbForPlatform->getDocument('teams', $teamId); - - if ($team->isEmpty()) { - throw new Exception(Exception::TEAM_NOT_FOUND); - } - - $allowList = \array_filter(\explode(',', System::getEnv('_APP_PROJECT_REGIONS', ''))); - - if (!empty($allowList) && !\in_array($region, $allowList)) { - throw new Exception(Exception::PROJECT_REGION_UNSUPPORTED, 'Region "' . $region . '" is not supported'); - } - - $auth = Config::getParam('auth', []); - $auths = [ - 'limit' => 0, - 'maxSessions' => APP_LIMIT_USER_SESSIONS_DEFAULT, - 'passwordHistory' => 0, - 'passwordDictionary' => false, - 'duration' => TOKEN_EXPIRATION_LOGIN_LONG, - 'personalDataCheck' => false, - 'mockNumbers' => [], - 'sessionAlerts' => false, - 'membershipsUserName' => false, - 'membershipsUserEmail' => false, - 'membershipsMfa' => false, - 'invalidateSessions' => true - ]; - - foreach ($auth as $method) { - $auths[$method['key'] ?? ''] = true; - } - - $projectId = ($projectId == 'unique()') ? ID::unique() : $projectId; - - if ($projectId === 'console') { - throw new Exception(Exception::PROJECT_RESERVED_PROJECT, "'console' is a reserved project."); - } - - $databases = Config::getParam('pools-database', []); - - if ($region !== 'default') { - $databaseKeys = System::getEnv('_APP_DATABASE_KEYS', ''); - $keys = explode(',', $databaseKeys); - $databases = array_filter($keys, function ($value) use ($region) { - return str_contains($value, $region); - }); - } - - $databaseOverride = System::getEnv('_APP_DATABASE_OVERRIDE'); - $index = \array_search($databaseOverride, $databases); - if ($index !== false) { - $dsn = $databases[$index]; - } else { - $dsn = $databases[array_rand($databases)]; - } - - // TODO: Temporary until all projects are using shared tables. - $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); - - if (\in_array($dsn, $sharedTables)) { - $schema = 'appwrite'; - $database = 'appwrite'; - $namespace = System::getEnv('_APP_DATABASE_SHARED_NAMESPACE', ''); - $dsn = $schema . '://' . $dsn . '?database=' . $database; - - if (!empty($namespace)) { - $dsn .= '&namespace=' . $namespace; - } - } - - try { - $project = $dbForPlatform->createDocument('projects', new Document([ - '$id' => $projectId, - '$permissions' => [ - Permission::read(Role::team(ID::custom($teamId))), - Permission::update(Role::team(ID::custom($teamId), 'owner')), - Permission::update(Role::team(ID::custom($teamId), 'developer')), - Permission::delete(Role::team(ID::custom($teamId), 'owner')), - Permission::delete(Role::team(ID::custom($teamId), 'developer')), - ], - 'name' => $name, - 'teamInternalId' => $team->getSequence(), - 'teamId' => $team->getId(), - 'region' => $region, - 'description' => $description, - 'logo' => $logo, - 'url' => $url, - 'version' => APP_VERSION_STABLE, - 'legalName' => $legalName, - 'legalCountry' => $legalCountry, - 'legalState' => $legalState, - 'legalCity' => $legalCity, - 'legalAddress' => $legalAddress, - 'legalTaxId' => ID::custom($legalTaxId), - 'services' => new stdClass(), - 'platforms' => null, - 'oAuthProviders' => [], - 'webhooks' => null, - 'keys' => null, - 'auths' => $auths, - 'accessedAt' => DateTime::now(), - 'search' => implode(' ', [$projectId, $name]), - 'database' => $dsn, - 'labels' => [], - ])); - } catch (Duplicate) { - throw new Exception(Exception::PROJECT_ALREADY_EXISTS); - } - - try { - $dsn = new DSN($dsn); - } catch (\InvalidArgumentException) { - // TODO: Temporary until all projects are using shared tables - $dsn = new DSN('mysql://' . $dsn); - } - - $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); - $sharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES_V1', '')); - $projectTables = !\in_array($dsn->getHost(), $sharedTables); - $sharedTablesV1 = \in_array($dsn->getHost(), $sharedTablesV1); - $sharedTablesV2 = !$projectTables && !$sharedTablesV1; - $sharedTables = $sharedTablesV1 || $sharedTablesV2; - - if (!$sharedTablesV2) { - $adapter = new DatabasePool($pools->get($dsn->getHost())); - $dbForProject = new Database($adapter, $cache); - $dbForProject->setDatabase(APP_DATABASE); - - if ($sharedTables) { - $dbForProject - ->setSharedTables(true) - ->setTenant($sharedTablesV1 ? (int)$project->getSequence() : null) - ->setNamespace($dsn->getParam('namespace')); - } else { - $dbForProject - ->setSharedTables(false) - ->setTenant(null) - ->setNamespace('_' . $project->getSequence()); - } - - $create = true; - - try { - $dbForProject->create(); - } catch (Duplicate) { - $create = false; - } - - if ($create || $projectTables) { - $adapter = new AdapterDatabase($dbForProject); - $audit = new Audit($adapter); - $audit->setup(); - } - - if (!$create && $sharedTablesV1) { - $adapter = new AdapterDatabase($dbForProject); - $attributes = $adapter->getAttributeDocuments(); - $indexes = $adapter->getIndexDocuments(); - $dbForProject->createDocument(Database::METADATA, new Document([ - '$id' => ID::custom('audit'), - '$permissions' => [Permission::create(Role::any())], - 'name' => 'audit', - 'attributes' => $attributes, - 'indexes' => $indexes, - 'documentSecurity' => true - ])); - } - - if ($create || $sharedTablesV1) { - /** @var array $collections */ - $collections = Config::getParam('collections', [])['projects'] ?? []; - - foreach ($collections as $key => $collection) { - if (($collection['$collection'] ?? '') !== Database::METADATA) { - continue; - } - - $attributes = \array_map(fn ($attribute) => new Document($attribute), $collection['attributes']); - $indexes = \array_map(fn (array $index) => new Document($index), $collection['indexes']); - - try { - $dbForProject->createCollection($key, $attributes, $indexes); - } catch (Duplicate) { - $dbForProject->createDocument(Database::METADATA, new Document([ - '$id' => ID::custom($key), - '$permissions' => [Permission::create(Role::any())], - 'name' => $key, - 'attributes' => $attributes, - 'indexes' => $indexes, - 'documentSecurity' => true - ])); - } - } - } - } - - // Hook allowing instant project mirroring during migration - // Outside of migration, hook is not registered and has no effect - $hooks->trigger('afterProjectCreation', [$project, $pools, $cache]); - - $response - ->setStatusCode(Response::STATUS_CODE_CREATED) - ->dynamic($project, Response::MODEL_PROJECT); - }); - App::get('/v1/projects/:projectId') ->desc('Get project') ->groups(['api', 'projects']) @@ -337,137 +80,6 @@ App::get('/v1/projects/:projectId') $response->dynamic($project, Response::MODEL_PROJECT); }); -App::patch('/v1/projects/:projectId') - ->desc('Update project') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('audits.event', 'projects.update') - ->label('audits.resource', 'project/{request.projectId}') - ->label('sdk', new Method( - namespace: 'projects', - group: 'projects', - name: 'update', - description: '/docs/references/projects/update.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', new UID(), 'Project unique ID.') - ->param('name', null, new Text(128), 'Project name. Max length: 128 chars.') - ->param('description', '', new Text(256), 'Project description. Max length: 256 chars.', true) - ->param('logo', '', new Text(1024), 'Project logo.', true) - ->param('url', '', new URL(), 'Project URL.', true) - ->param('legalName', '', new Text(256), 'Project legal name. Max length: 256 chars.', true) - ->param('legalCountry', '', new Text(256), 'Project legal country. Max length: 256 chars.', true) - ->param('legalState', '', new Text(256), 'Project legal state. Max length: 256 chars.', true) - ->param('legalCity', '', new Text(256), 'Project legal city. Max length: 256 chars.', true) - ->param('legalAddress', '', new Text(256), 'Project legal address. Max length: 256 chars.', true) - ->param('legalTaxId', '', new Text(256), 'Project legal tax ID. Max length: 256 chars.', true) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $name, string $description, string $logo, string $url, string $legalName, string $legalCountry, string $legalState, string $legalCity, string $legalAddress, string $legalTaxId, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project - ->setAttribute('name', $name) - ->setAttribute('description', $description) - ->setAttribute('logo', $logo) - ->setAttribute('url', $url) - ->setAttribute('legalName', $legalName) - ->setAttribute('legalCountry', $legalCountry) - ->setAttribute('legalState', $legalState) - ->setAttribute('legalCity', $legalCity) - ->setAttribute('legalAddress', $legalAddress) - ->setAttribute('legalTaxId', $legalTaxId) - ->setAttribute('search', implode(' ', [$projectId, $name]))); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -App::patch('/v1/projects/:projectId/team') - ->desc('Update project team') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'projects', - name: 'updateTeam', - description: '/docs/references/projects/update-team.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', new UID(), 'Project unique ID.') - ->param('teamId', '', new UID(), 'Team ID of the team to transfer project to.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $teamId, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - $team = $dbForPlatform->getDocument('teams', $teamId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - if ($team->isEmpty()) { - throw new Exception(Exception::TEAM_NOT_FOUND); - } - - $permissions = [ - Permission::read(Role::team(ID::custom($teamId))), - Permission::update(Role::team(ID::custom($teamId), 'owner')), - Permission::update(Role::team(ID::custom($teamId), 'developer')), - Permission::delete(Role::team(ID::custom($teamId), 'owner')), - Permission::delete(Role::team(ID::custom($teamId), 'developer')), - ]; - - $project - ->setAttribute('teamId', $teamId) - ->setAttribute('teamInternalId', $team->getSequence()) - ->setAttribute('$permissions', $permissions); - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project); - - $installations = $dbForPlatform->find('installations', [ - Query::equal('projectInternalId', [$project->getSequence()]), - ]); - foreach ($installations as $installation) { - $installation->getAttribute('$permissions', $permissions); - $dbForPlatform->updateDocument('installations', $installation->getId(), $installation); - } - - $repositories = $dbForPlatform->find('repositories', [ - Query::equal('projectInternalId', [$project->getSequence()]), - ]); - foreach ($repositories as $repository) { - $repository->getAttribute('$permissions', $permissions); - $dbForPlatform->updateDocument('repositories', $repository->getId(), $repository); - } - - $vcsComments = $dbForPlatform->find('vcsComments', [ - Query::equal('projectInternalId', [$project->getSequence()]), - ]); - foreach ($vcsComments as $vcsComment) { - $vcsComment->getAttribute('$permissions', $permissions); - $dbForPlatform->updateDocument('vcsComments', $vcsComment->getId(), $vcsComment); - } - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - App::patch('/v1/projects/:projectId/service') ->desc('Update service status') ->groups(['api', 'projects']) diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index 2cee394a9c..8745dbddd4 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -58,7 +58,6 @@ use Utopia\Validator\ArrayList; use Utopia\Validator\Assoc; use Utopia\Validator\Boolean; use Utopia\Validator\Text; -use Utopia\Validator\WhiteList; App::post('/v1/teams') ->desc('Create team') @@ -477,16 +476,7 @@ App::post('/v1/teams/:teamId/memberships') ->param('email', '', new EmailValidator(), 'Email of the new team member.', true) ->param('userId', '', new UID(), 'ID of the user to be added to a team.', true) ->param('phone', '', new Phone(), 'Phone number. Format this number with a leading \'+\' and a country code, e.g., +16175551212.', true) - ->param('roles', [], function (Document $project) { - if ($project->getId() === 'console') { - $roles = array_keys(Config::getParam('roles', [])); - $roles = array_filter($roles, function ($role) { - return !in_array($role, [User::ROLE_APPS, User::ROLE_GUESTS, User::ROLE_USERS]); - }); - return new ArrayList(new WhiteList($roles), APP_LIMIT_ARRAY_PARAMS_SIZE); - } - return new ArrayList(new Key(), APP_LIMIT_ARRAY_PARAMS_SIZE); - }, 'Array of strings. Use this param to set the user roles in the team. A role can be any string. Learn more about [roles and permissions](https://appwrite.io/docs/permissions). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' roles are allowed, each 32 characters long.', false, ['project']) + ->param('roles', [], new ArrayList(new Key(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of strings. Use this param to set the user roles in the team. A role can be any string. Learn more about [roles and permissions](https://appwrite.io/docs/permissions). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' roles are allowed, each 32 characters long.', false, ['project']) ->param('url', '', fn ($redirectValidator) => $redirectValidator, 'URL to redirect the user back to your app from the invitation email. This parameter is not required when an API key is supplied. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['redirectValidator']) // TODO add our own built-in confirm page ->param('name', '', new Text(128), 'Name of the new team member. Max length: 128 chars.', true) ->inject('response') @@ -1088,16 +1078,7 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId') )) ->param('teamId', '', new UID(), 'Team ID.') ->param('membershipId', '', new UID(), 'Membership ID.') - ->param('roles', [], function (Document $project) { - if ($project->getId() === 'console') { - $roles = array_keys(Config::getParam('roles', [])); - $roles = array_filter($roles, function ($role) { - return !in_array($role, [User::ROLE_APPS, User::ROLE_GUESTS, User::ROLE_USERS]); - }); - return new ArrayList(new WhiteList($roles), APP_LIMIT_ARRAY_PARAMS_SIZE); - } - return new ArrayList(new Key(), APP_LIMIT_ARRAY_PARAMS_SIZE); - }, 'An array of strings. Use this param to set the user\'s roles in the team. A role can be any string. Learn more about [roles and permissions](https://appwrite.io/docs/permissions). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' roles are allowed, each 32 characters long.', false, ['project']) + ->param('roles', [], new ArrayList(new Key(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'An array of strings. Use this param to set the user\'s roles in the team. A role can be any string. Learn more about [roles and permissions](https://appwrite.io/docs/permissions). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' roles are allowed, each 32 characters long.', false, ['project']) ->inject('request') ->inject('response') ->inject('user') diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index fffe544330..cbbf419634 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -244,11 +244,14 @@ App::init() } $scopes = []; // Reset scope if admin - foreach ($adminRoles as $role) { - $scopes = \array_merge($scopes, $roles[$role]['scopes']); - } - $authorization->setDefaultStatus(false); // Cancel security segmentation for admin users. + foreach ($adminRoles as $role) { + if (str_starts_with($role, 'project-')) { + $role = substr($role, strrpos($role, '-') + 1); + } + $scopes = \array_merge($scopes, $roles[$role]['scopes']); + $authorization->addRole($role); + } } $scopes = \array_unique($scopes); diff --git a/composer.json b/composer.json index f5bab03697..7cf759c497 100644 --- a/composer.json +++ b/composer.json @@ -52,7 +52,7 @@ "utopia-php/cache": "0.13.*", "utopia-php/cli": "0.15.*", "utopia-php/config": "1.*", - "utopia-php/database": "4.*", + "utopia-php/database": "dev-ser-541-tag-4.5.2 as 4.0.99", "utopia-php/detector": "0.2.*", "utopia-php/domains": "0.11.*", "utopia-php/emails": "0.6.*", @@ -108,5 +108,11 @@ "php-http/discovery": true, "tbachert/spi": true } - } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/utopia-php/database" + } + ] } diff --git a/composer.lock b/composer.lock index bd56277819..0cc35eaad0 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "33da844fdf5648d1d1a027dfb6ae42bc", + "content-hash": "6df869889de657693cbd12e2cf4cf781", "packages": [ { "name": "adhocore/jwt", @@ -3961,16 +3961,16 @@ }, { "name": "utopia-php/database", - "version": "4.6.2", + "version": "dev-ser-541-tag-4.5.2", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "53394759c44067e9db4660635765e2056f83788c" + "reference": "65e6846eec15c8e89c206ac2f4dbe766cf708814" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/53394759c44067e9db4660635765e2056f83788c", - "reference": "53394759c44067e9db4660635765e2056f83788c", + "url": "https://api.github.com/repos/utopia-php/database/zipball/65e6846eec15c8e89c206ac2f4dbe766cf708814", + "reference": "65e6846eec15c8e89c206ac2f4dbe766cf708814", "shasum": "" }, "require": { @@ -3999,7 +3999,38 @@ "Utopia\\Database\\": "src/Database" } }, - "notification-url": "https://packagist.org/downloads/", + "autoload-dev": { + "psr-4": { + "Tests\\E2E\\": "tests/e2e", + "Tests\\Unit\\": "tests/unit" + } + }, + "scripts": { + "build": [ + "Composer\\Config::disableProcessTimeout", + "docker compose build" + ], + "start": [ + "Composer\\Config::disableProcessTimeout", + "docker compose up -d" + ], + "test": [ + "Composer\\Config::disableProcessTimeout", + "docker compose exec tests vendor/bin/phpunit --configuration phpunit.xml" + ], + "lint": [ + "php -d memory_limit=2G ./vendor/bin/pint --test" + ], + "format": [ + "php -d memory_limit=2G ./vendor/bin/pint" + ], + "check": [ + "./vendor/bin/phpstan analyse --level 7 src tests --memory-limit 2G" + ], + "coverage": [ + "./vendor/bin/coverage-check ./tmp/clover.xml 90" + ] + }, "license": [ "MIT" ], @@ -4012,10 +4043,10 @@ "utopia" ], "support": { - "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/4.6.2" + "source": "https://github.com/utopia-php/database/tree/ser-541-tag-4.5.2", + "issues": "https://github.com/utopia-php/database/issues" }, - "time": "2026-01-22T07:14:12+00:00" + "time": "2026-01-28T12:53:24+00:00" }, { "name": "utopia-php/detector", @@ -9049,9 +9080,18 @@ "time": "2024-03-07T20:33:40+00:00" } ], - "aliases": [], + "aliases": [ + { + "package": "utopia-php/database", + "version": "dev-ser-541-tag-4.5.2", + "alias": "4.0.99", + "alias_normalized": "4.0.99.0" + } + ], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": { + "utopia-php/database": 20 + }, "prefer-stable": false, "prefer-lowest": false, "platform": { diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Action.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Action.php new file mode 100644 index 0000000000..607356dbd6 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Action.php @@ -0,0 +1,30 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/projects') + ->desc('Create project') + ->groups(['api', 'projects']) + ->label('audits.event', 'projects.create') + ->label('audits.resource', 'project/{response.$id}') + ->label('scope', 'projects.write') + ->label('sdk', new Method( + namespace: 'projects', + group: 'projects', + name: 'create', + description: '/docs/references/projects/create.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_CREATED, + model: Response::MODEL_PROJECT, + ) + ] + )) + ->param('projectId', '', new ProjectId(), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, and hyphen. Can\'t start with a special char. Max length is 36 chars.') + ->param('name', null, new Text(128), 'Project name. Max length: 128 chars.') + ->param('teamId', '', new UID(), 'Team unique ID.') + ->param('region', System::getEnv('_APP_REGION', 'default'), new WhiteList(array_keys(array_filter(Config::getParam('regions'), fn ($config) => !$config['disabled']))), 'Project Region.', true) + ->param('description', '', new Text(256), 'Project description. Max length: 256 chars.', true) + ->param('logo', '', new Text(1024), 'Project logo.', true) + ->param('url', '', new URL(), 'Project URL.', true) + ->param('legalName', '', new Text(256), 'Project legal Name. Max length: 256 chars.', true) + ->param('legalCountry', '', new Text(256), 'Project legal Country. Max length: 256 chars.', true) + ->param('legalState', '', new Text(256), 'Project legal State. Max length: 256 chars.', true) + ->param('legalCity', '', new Text(256), 'Project legal City. Max length: 256 chars.', true) + ->param('legalAddress', '', new Text(256), 'Project legal Address. Max length: 256 chars.', true) + ->param('legalTaxId', '', new Text(256), 'Project legal Tax ID. Max length: 256 chars.', true) + ->inject('request') + ->inject('response') + ->inject('dbForPlatform') + ->inject('cache') + ->inject('pools') + ->inject('hooks') + ->callback($this->action(...)); + } + + public function action(string $projectId, string $name, string $teamId, string $region, string $description, string $logo, string $url, string $legalName, string $legalCountry, string $legalState, string $legalCity, string $legalAddress, string $legalTaxId, Request $request, Response $response, Database $dbForPlatform, Cache $cache, Group $pools, Hooks $hooks) + { + $team = $dbForPlatform->getDocument('teams', $teamId); + + if ($team->isEmpty()) { + throw new Exception(Exception::TEAM_NOT_FOUND); + } + + $allowList = \array_filter(\explode(',', System::getEnv('_APP_PROJECT_REGIONS', ''))); + + if (!empty($allowList) && !\in_array($region, $allowList)) { + throw new Exception(Exception::PROJECT_REGION_UNSUPPORTED, 'Region "' . $region . '" is not supported'); + } + + $auth = Config::getParam('auth', []); + $auths = [ + 'limit' => 0, + 'maxSessions' => APP_LIMIT_USER_SESSIONS_DEFAULT, + 'passwordHistory' => 0, + 'passwordDictionary' => false, + 'duration' => TOKEN_EXPIRATION_LOGIN_LONG, + 'personalDataCheck' => false, + 'mockNumbers' => [], + 'sessionAlerts' => false, + 'membershipsUserName' => false, + 'membershipsUserEmail' => false, + 'membershipsMfa' => false, + 'invalidateSessions' => true + ]; + + foreach ($auth as $method) { + $auths[$method['key'] ?? ''] = true; + } + + $projectId = ($projectId == 'unique()') ? ID::unique() : $projectId; + + if ($projectId === 'console') { + throw new Exception(Exception::PROJECT_RESERVED_PROJECT, "'console' is a reserved project."); + } + + $databases = Config::getParam('pools-database', []); + + if ($region !== 'default') { + $databaseKeys = System::getEnv('_APP_DATABASE_KEYS', ''); + $keys = explode(',', $databaseKeys); + $databases = array_filter($keys, function ($value) use ($region) { + return str_contains($value, $region); + }); + } + + $databaseOverride = System::getEnv('_APP_DATABASE_OVERRIDE'); + $index = \array_search($databaseOverride, $databases); + if ($index !== false) { + $dsn = $databases[$index]; + } else { + $dsn = $databases[array_rand($databases)]; + } + + // TODO: Temporary until all projects are using shared tables. + $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); + + if (\in_array($dsn, $sharedTables)) { + $schema = 'appwrite'; + $database = 'appwrite'; + $namespace = System::getEnv('_APP_DATABASE_SHARED_NAMESPACE', ''); + $dsn = $schema . '://' . $dsn . '?database=' . $database; + + if (!empty($namespace)) { + $dsn .= '&namespace=' . $namespace; + } + } + + try { + $project = $dbForPlatform->createDocument('projects', new Document([ + '$id' => $projectId, + '$permissions' => $this->getPermissions($teamId, $projectId), + 'name' => $name, + 'teamInternalId' => $team->getSequence(), + 'teamId' => $team->getId(), + 'region' => $region, + 'description' => $description, + 'logo' => $logo, + 'url' => $url, + 'version' => APP_VERSION_STABLE, + 'legalName' => $legalName, + 'legalCountry' => $legalCountry, + 'legalState' => $legalState, + 'legalCity' => $legalCity, + 'legalAddress' => $legalAddress, + 'legalTaxId' => ID::custom($legalTaxId), + 'services' => new \stdClass(), + 'platforms' => null, + 'oAuthProviders' => [], + 'webhooks' => null, + 'keys' => null, + 'auths' => $auths, + 'accessedAt' => DateTime::now(), + 'search' => implode(' ', [$projectId, $name]), + 'database' => $dsn, + 'labels' => [], + ])); + } catch (Duplicate) { + throw new Exception(Exception::PROJECT_ALREADY_EXISTS); + } + + try { + $dsn = new DSN($dsn); + } catch (\InvalidArgumentException) { + // TODO: Temporary until all projects are using shared tables + $dsn = new DSN('mysql://' . $dsn); + } + + $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); + $sharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES_V1', '')); + $projectTables = !\in_array($dsn->getHost(), $sharedTables); + $sharedTablesV1 = \in_array($dsn->getHost(), $sharedTablesV1); + $sharedTablesV2 = !$projectTables && !$sharedTablesV1; + $sharedTables = $sharedTablesV1 || $sharedTablesV2; + + if (!$sharedTablesV2) { + $adapter = new DatabasePool($pools->get($dsn->getHost())); + $dbForProject = new Database($adapter, $cache); + $dbForProject->setDatabase(APP_DATABASE); + + if ($sharedTables) { + $dbForProject + ->setSharedTables(true) + ->setTenant($sharedTablesV1 ? (int)$project->getSequence() : null) + ->setNamespace($dsn->getParam('namespace')); + } else { + $dbForProject + ->setSharedTables(false) + ->setTenant(null) + ->setNamespace('_' . $project->getSequence()); + } + + $create = true; + + try { + $dbForProject->create(); + } catch (Duplicate) { + $create = false; + } + + if ($create || $projectTables) { + $adapter = new AdapterDatabase($dbForProject); + $audit = new Audit($adapter); + $audit->setup(); + } + + if (!$create && $sharedTablesV1) { + $adapter = new AdapterDatabase($dbForProject); + $attributes = $adapter->getAttributeDocuments(); + $indexes = $adapter->getIndexDocuments(); + $dbForProject->createDocument(Database::METADATA, new Document([ + '$id' => ID::custom('audit'), + '$permissions' => [Permission::create(Role::any())], + 'name' => 'audit', + 'attributes' => $attributes, + 'indexes' => $indexes, + 'documentSecurity' => true + ])); + } + + if ($create || $sharedTablesV1) { + /** @var array $collections */ + $collections = Config::getParam('collections', [])['projects'] ?? []; + + foreach ($collections as $key => $collection) { + if (($collection['$collection'] ?? '') !== Database::METADATA) { + continue; + } + + $attributes = \array_map(fn ($attribute) => new Document($attribute), $collection['attributes']); + $indexes = \array_map(fn (array $index) => new Document($index), $collection['indexes']); + + try { + $dbForProject->createCollection($key, $attributes, $indexes); + } catch (Duplicate) { + $dbForProject->createDocument(Database::METADATA, new Document([ + '$id' => ID::custom($key), + '$permissions' => [Permission::create(Role::any())], + 'name' => $key, + 'attributes' => $attributes, + 'indexes' => $indexes, + 'documentSecurity' => true + ])); + } + } + } + } + + // Hook allowing instant project mirroring during migration + // Outside of migration, hook is not registered and has no effect + $hooks->trigger('afterProjectCreation', [$project, $pools, $cache]); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Team/Update.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Team/Update.php new file mode 100644 index 0000000000..2442efca63 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Team/Update.php @@ -0,0 +1,107 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/projects/:projectId/team') + ->desc('Update project team') + ->groups(['api', 'projects']) + ->label('scope', 'projects.write') + ->label('sdk', new Method( + namespace: 'projects', + group: 'projects', + name: 'updateTeam', + description: '/docs/references/projects/update-team.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_PROJECT, + ) + ] + )) + ->param('projectId', '', new UID(), 'Project unique ID.') + ->param('teamId', '', new UID(), 'Team ID of the team to transfer project to.') + ->inject('response') + ->inject('dbForPlatform') + ->callback($this->action(...)); + } + + public function action(string $projectId, string $teamId, Response $response, Database $dbForPlatform) + { + $project = $dbForPlatform->getDocument('projects', $projectId); + $team = $dbForPlatform->getDocument('teams', $teamId); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + if ($team->isEmpty()) { + throw new Exception(Exception::TEAM_NOT_FOUND); + } + + $permissions = $this->getPermissions($teamId, $projectId); + + $project + ->setAttribute('teamId', $teamId) + ->setAttribute('teamInternalId', $team->getSequence()) + ->setAttribute('$permissions', $permissions); + $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project); + + $installations = $dbForPlatform->find('installations', [ + Query::equal('projectInternalId', [$project->getSequence()]), + ]); + foreach ($installations as $installation) { + $installation->getAttribute('$permissions', $permissions); + $dbForPlatform->updateDocument('installations', $installation->getId(), $installation); + } + + $repositories = $dbForPlatform->find('repositories', [ + Query::equal('projectInternalId', [$project->getSequence()]), + ]); + foreach ($repositories as $repository) { + $repository->getAttribute('$permissions', $permissions); + $dbForPlatform->updateDocument('repositories', $repository->getId(), $repository); + } + + $vcsComments = $dbForPlatform->find('vcsComments', [ + Query::equal('projectInternalId', [$project->getSequence()]), + ]); + foreach ($vcsComments as $vcsComment) { + $vcsComment->getAttribute('$permissions', $permissions); + $dbForPlatform->updateDocument('vcsComments', $vcsComment->getId(), $vcsComment); + } + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Update.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Update.php new file mode 100644 index 0000000000..29c26b33ea --- /dev/null +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Update.php @@ -0,0 +1,94 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/projects/:projectId') + ->desc('Update project') + ->groups(['api', 'projects']) + ->label('scope', 'projects.write') + ->label('audits.event', 'projects.update') + ->label('audits.resource', 'project/{request.projectId}') + ->label('sdk', new Method( + namespace: 'projects', + group: 'projects', + name: 'update', + description: '/docs/references/projects/update.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_PROJECT, + ) + ] + )) + ->param('projectId', '', new UID(), 'Project unique ID.') + ->param('name', null, new Text(128), 'Project name. Max length: 128 chars.') + ->param('description', '', new Text(256), 'Project description. Max length: 256 chars.', true) + ->param('logo', '', new Text(1024), 'Project logo.', true) + ->param('url', '', new URL(), 'Project URL.', true) + ->param('legalName', '', new Text(256), 'Project legal name. Max length: 256 chars.', true) + ->param('legalCountry', '', new Text(256), 'Project legal country. Max length: 256 chars.', true) + ->param('legalState', '', new Text(256), 'Project legal state. Max length: 256 chars.', true) + ->param('legalCity', '', new Text(256), 'Project legal city. Max length: 256 chars.', true) + ->param('legalAddress', '', new Text(256), 'Project legal address. Max length: 256 chars.', true) + ->param('legalTaxId', '', new Text(256), 'Project legal tax ID. Max length: 256 chars.', true) + ->inject('response') + ->inject('dbForPlatform') + ->callback($this->action(...)); + } + + public function action(string $projectId, string $name, string $description, string $logo, string $url, string $legalName, string $legalCountry, string $legalState, string $legalCity, string $legalAddress, string $legalTaxId, Response $response, Database $dbForPlatform) + { + $project = $dbForPlatform->getDocument('projects', $projectId); + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + + $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project + ->setAttribute('name', $name) + ->setAttribute('description', $description) + ->setAttribute('logo', $logo) + ->setAttribute('url', $url) + ->setAttribute('legalName', $legalName) + ->setAttribute('legalCountry', $legalCountry) + ->setAttribute('legalState', $legalState) + ->setAttribute('legalCity', $legalCity) + ->setAttribute('legalAddress', $legalAddress) + ->setAttribute('legalTaxId', $legalTaxId) + ->setAttribute('search', implode(' ', [$projectId, $name]))); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Projects/Services/Http.php b/src/Appwrite/Platform/Modules/Projects/Services/Http.php index cce05a9570..587f101d61 100644 --- a/src/Appwrite/Platform/Modules/Projects/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Projects/Services/Http.php @@ -7,7 +7,10 @@ use Appwrite\Platform\Modules\Projects\Http\DevKeys\Delete as DeleteDevKey; use Appwrite\Platform\Modules\Projects\Http\DevKeys\Get as GetDevKey; use Appwrite\Platform\Modules\Projects\Http\DevKeys\Update as UpdateDevKey; use Appwrite\Platform\Modules\Projects\Http\DevKeys\XList as ListDevKeys; +use Appwrite\Platform\Modules\Projects\Http\Projects\Create as CreateProject; use Appwrite\Platform\Modules\Projects\Http\Projects\Labels\Update as UpdateProjectLabels; +use Appwrite\Platform\Modules\Projects\Http\Projects\Team\Update as UpdateProjectTeam; +use Appwrite\Platform\Modules\Projects\Http\Projects\Update as UpdateProject; use Appwrite\Platform\Modules\Projects\Http\Projects\XList as ListProjects; use Utopia\Platform\Service; @@ -22,7 +25,10 @@ class Http extends Service $this->addAction(ListDevKeys::getName(), new ListDevKeys()); $this->addAction(DeleteDevKey::getName(), new DeleteDevKey()); + $this->addAction(CreateProject::getName(), new CreateProject()); + $this->addAction(UpdateProject::getName(), new UpdateProject()); $this->addAction(ListProjects::getName(), new ListProjects()); $this->addAction(UpdateProjectLabels::getName(), new UpdateProjectLabels()); + $this->addAction(UpdateProjectTeam::getName(), new UpdateProjectTeam()); } } diff --git a/src/Appwrite/Utopia/Database/Documents/User.php b/src/Appwrite/Utopia/Database/Documents/User.php index cbd22aaee5..2c800f77b9 100644 --- a/src/Appwrite/Utopia/Database/Documents/User.php +++ b/src/Appwrite/Utopia/Database/Documents/User.php @@ -39,7 +39,7 @@ class User extends Document { $roles = []; - if (!$this->isPrivileged($authorization->getRoles()) && !$this->isApp($authorization->getRoles())) { + if (!$this->isApp($authorization->getRoles())) { if ($this->getId()) { $roles[] = Role::user($this->getId())->toString(); $roles[] = Role::users()->toString(); @@ -71,6 +71,11 @@ class User extends Document if (isset($node['roles'])) { foreach ($node['roles'] as $nodeRole) { // Set all team roles $roles[] = Role::team($node['teamId'], $nodeRole)->toString(); + + if (str_starts_with($nodeRole, 'project-')) { + $projectBaseRole = substr($nodeRole, 0, strrpos($nodeRole, '-')); + $roles[] = Role::team($node['teamId'], $projectBaseRole)->toString(); + } } } } diff --git a/tests/e2e/Services/Projects/ProjectsBase.php b/tests/e2e/Services/Projects/ProjectsBase.php index 0d1d6a5a44..89ce886727 100644 --- a/tests/e2e/Services/Projects/ProjectsBase.php +++ b/tests/e2e/Services/Projects/ProjectsBase.php @@ -7,24 +7,28 @@ use Utopia\Database\Helpers\ID; trait ProjectsBase { - protected function setupProject(mixed $params): string + protected function setupProject(mixed $params, string $teamId = null, bool $newTeam = true): string { - $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'teamId' => ID::unique(), - 'name' => 'Project Test', - ]); + if ($newTeam) { + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'teamId' => $teamId ?? ID::unique(), + 'name' => 'Project Test', + ]); - $this->assertEquals(201, $team['headers']['status-code'], 'Setup team failed with status code: ' . $team['headers']['status-code'] . ' and response: ' . json_encode($team['body'], JSON_PRETTY_PRINT)); + $this->assertEquals(201, $team['headers']['status-code'], 'Setup team failed with status code: ' . $team['headers']['status-code'] . ' and response: ' . json_encode($team['body'], JSON_PRETTY_PRINT)); + + $teamId = $team['body']['$id']; + } $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ ...$params, - 'teamId' => $team['body']['$id'], + 'teamId' => $teamId, ]); $this->assertEquals(201, $project['headers']['status-code'], 'Setup project failed with status code: ' . $project['headers']['status-code'] . ' and response: ' . json_encode($project['body'], JSON_PRETTY_PRINT)); @@ -46,4 +50,93 @@ trait ProjectsBase 'secret' => $devKey['body']['secret'], ]; } + + protected function setupUserMembership(mixed $params): array + { + // Create membership + $response = $this->client->call(Client::METHOD_POST, '/teams/' . $params['teamId'] . '/memberships', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'email' => $params['email'], + 'name' => $params['name'], + 'roles' => $params['roles'], + 'url' => 'http://localhost:5000/join-us#title' + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertNotEmpty($response['body']['userId']); + $this->assertEquals($params['name'], $response['body']['userName']); + $this->assertEquals($params['email'], $response['body']['userEmail']); + $this->assertNotEmpty($response['body']['teamId']); + $this->assertCount(count($params['roles']), $response['body']['roles']); + $this->assertEquals(false, $response['body']['confirm']); + + $userId = $response['body']['userId']; + $membershipId = $response['body']['$id']; + + + $lastEmail = $this->getLastEmail(); + $tokens = $this->extractQueryParamsFromEmailLink($lastEmail['html']); + $userId = $tokens['userId']; + $secret = $tokens['secret']; + + // Confirm membership + $response = $this->client->call(Client::METHOD_PATCH, '/teams/' . $params['teamId'] . '/memberships/' . $membershipId . '/status', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'userId' => $userId, + 'secret' => $secret, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertNotEmpty($response['body']['userId']); + $this->assertNotEmpty($response['body']['teamId']); + $this->assertCount(count($params['roles']), $response['body']['roles']); + $this->assertEquals(true, $response['body']['confirm']); + + // Simulate password recovery flow to reset password for the created user (useful when creating session for this user) + $response = $this->client->call(Client::METHOD_POST, '/account/recovery', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'email' => $params['email'], + 'url' => 'http://localhost/recovery', + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertEmpty($response['body']['secret']); + + $lastEmail = $this->getLastEmail(); + $this->assertEquals($params['email'], $lastEmail['to'][0]['address']); + $this->assertEquals($params['name'], $lastEmail['to'][0]['name']); + $this->assertEquals('Password Reset for ' . $this->getProject()['name'], $lastEmail['subject']); + $this->assertStringContainsStringIgnoringCase('Reset your ' . $this->getProject()['name'] . ' password using the link.', $lastEmail['text']); + + $tokens = $this->extractQueryParamsFromEmailLink($lastEmail['html']); + $secret = $tokens['secret']; + + $response = $this->client->call(Client::METHOD_PUT, '/account/recovery', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'userId' => $userId, + 'secret' => $secret, + 'password' => 'password', + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + return [ + 'userId' => $userId, + 'membershipId' => $membershipId, + ]; + } } diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index e31331574f..1f20456418 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -5594,4 +5594,290 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(204, $response['headers']['status-code']); } + + public function testProjectSpecificPermissionsForListProjects(): void + { + $teamId = ID::unique(); + $projectIdA = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'Project Test A', + 'region' => System::getEnv('_APP_REGION', 'default') + ], $teamId); + $projectIdB = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'Project Test B', + 'region' => System::getEnv('_APP_REGION', 'default') + ], $teamId, false); + + $teamOwnerEmail = 'team-' . ID::unique() . '-owner@localhost.test'; + $teamOwnerName = 'Team - owner'; + $teamDeveloperAndProjectAOwnerEmail = 'teamdeveloperandprojecta-' . ID::unique() . '-owner@localhost.test'; + $teamDeveloperAndProjectAOwnerName = 'Team Developer and Project A - owner'; + $projectAOwnerEmail = 'projecta-' . ID::unique() . '-owner@localhost.test'; + $projectAOwnerName = 'Project A - owner'; + $projectBOwnerEmail = 'projectb-' . ID::unique() . '-owner@localhost.test'; + $projectBOwnerName = 'Project B - owner'; + $this->setupUserMembership([ + 'teamId' => $teamId, + 'email' => $teamOwnerEmail, + 'name' => $teamOwnerName, + 'roles' => ["owner"], + ]); + $this->setupUserMembership([ + 'teamId' => $teamId, + 'email' => $teamDeveloperAndProjectAOwnerEmail, + 'name' => $teamDeveloperAndProjectAOwnerName, + 'roles' => ["developer", "project-$projectIdA-owner"], + ]); + $this->setupUserMembership([ + 'teamId' => $teamId, + 'email' => $projectAOwnerEmail, + 'name' => $projectAOwnerName, + 'roles' => ["project-$projectIdA-owner"], + ]); + $this->setupUserMembership([ + 'teamId' => $teamId, + 'email' => $projectBOwnerEmail, + 'name' => $projectBOwnerName, + 'roles' => ["project-$projectIdB-owner"], + ]); + + $testCases = [ + ['userEmail' => $teamOwnerEmail, 'accessibleProjectIds' => [$projectIdA, $projectIdB]], + ['userEmail' => $teamDeveloperAndProjectAOwnerEmail, 'accessibleProjectIds' => [$projectIdA, $projectIdB]], + ['userEmail' => $projectAOwnerEmail, 'accessibleProjectIds' => [$projectIdA]], + ['userEmail' => $projectBOwnerEmail, 'accessibleProjectIds' => [$projectIdB]], + ]; + + foreach ($testCases as $testCase) { + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'email' => $testCase['userEmail'], + 'password' => 'password', + ]); + $token = $session['cookies']['a_session_' . $this->getProject()['$id']]; + + $response = $this->client->call(Client::METHOD_GET, '/projects', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $token, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + $this->assertCount(\count($testCase['accessibleProjectIds']), $response['body']['projects']); + + $returnedProjectIds = \array_column($response['body']['projects'], '$id'); + foreach ($testCase['accessibleProjectIds'] as $projectId) { + $this->assertContains($projectId, $returnedProjectIds); + } + } + } + + public function testProjectSpecificPermissionsForUpdateProjects(): void + { + $teamId = ID::unique(); + $projectIdA = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'Project Test A', + 'region' => System::getEnv('_APP_REGION', 'default') + ], $teamId); + $projectIdB = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'Project Test B', + 'region' => System::getEnv('_APP_REGION', 'default') + ], $teamId, false); + + $teamOwnerEmail = 'team-' . ID::unique() . '-owner@localhost.test'; + $teamOwnerName = 'Team - owner'; + $teamDeveloperAndProjectAOwnerEmail = 'teamdeveloperandprojecta-' . ID::unique() . '-owner@localhost.test'; + $teamDeveloperAndProjectAOwnerName = 'Team Developer and Project A - owner'; + $projectAOwnerEmail = 'projecta-' . ID::unique() . '-owner@localhost.test'; + $projectAOwnerName = 'Project A - owner'; + $projectBOwnerEmail = 'projectb-' . ID::unique() . '-owner@localhost.test'; + $projectBOwnerName = 'Project B - owner'; + $this->setupUserMembership([ + 'teamId' => $teamId, + 'email' => $teamOwnerEmail, + 'name' => $teamOwnerName, + 'roles' => ["owner"], + ]); + $this->setupUserMembership([ + 'teamId' => $teamId, + 'email' => $teamDeveloperAndProjectAOwnerEmail, + 'name' => $teamDeveloperAndProjectAOwnerName, + 'roles' => ["developer", "project-$projectIdA-owner"], + ]); + $this->setupUserMembership([ + 'teamId' => $teamId, + 'email' => $projectAOwnerEmail, + 'name' => $projectAOwnerName, + 'roles' => ["project-$projectIdA-owner"], + ]); + $this->setupUserMembership([ + 'teamId' => $teamId, + 'email' => $projectBOwnerEmail, + 'name' => $projectBOwnerName, + 'roles' => ["project-$projectIdB-owner"], + ]); + + $testCases = [ + ['userEmail' => $teamOwnerEmail, 'accessibleProjectIds' => [$projectIdA, $projectIdB], 'inaccessibleProjectIds' => []], + ['userEmail' => $teamDeveloperAndProjectAOwnerEmail, 'accessibleProjectIds' => [$projectIdA, $projectIdB], 'inaccessibleProjectIds' => []], + ['userEmail' => $projectAOwnerEmail, 'accessibleProjectIds' => [$projectIdA], 'inaccessibleProjectIds' => [$projectIdB]], + ['userEmail' => $projectBOwnerEmail, 'accessibleProjectIds' => [$projectIdB], 'inaccessibleProjectIds' => [$projectIdA]], + ]; + + foreach ($testCases as $testCase) { + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'email' => $testCase['userEmail'], + 'password' => 'password', + ]); + $token = $session['cookies']['a_session_' . $this->getProject()['$id']]; + + foreach ($testCase['accessibleProjectIds'] as $projectId) { + $newProjectName = 'Updated Project Name ' . ID::unique(); + // Success: User should be able to update the project they have access to. + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId, [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $token, + ], [ + 'name' => $newProjectName, + ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + $this->assertEquals($newProjectName, $response['body']['name']); + } + + foreach ($testCase['inaccessibleProjectIds'] as $projectId) { + $newProjectName = 'Updated Project Name ' . ID::unique(); + // Failure: User should not be able to update the project they do not have access to. + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId, [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $token, + ], [ + 'name' => $newProjectName, + ]); + + $this->assertEquals(404, $response['headers']['status-code'], \var_export($testCase, true)); + } + } + } + + public function testProjectSpecificPermissionsForDeleteProjects(): void + { + $teamId = ID::unique(); + $projectIdA = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'Project Test A', + 'region' => System::getEnv('_APP_REGION', 'default') + ], $teamId); + $projectIdB = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'Project Test B', + 'region' => System::getEnv('_APP_REGION', 'default') + ], $teamId, false); + $projectIdC = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'Project Test C', + 'region' => System::getEnv('_APP_REGION', 'default') + ], $teamId, false); + $projectIdD = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'Project Test D', + 'region' => System::getEnv('_APP_REGION', 'default') + ], $teamId, false); + $projectIdE = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'Project Test E', + 'region' => System::getEnv('_APP_REGION', 'default') + ], $teamId, false); + + $teamOwnerEmail = 'team-' . ID::unique() . '-owner@localhost.test'; + $teamOwnerName = 'Team - owner'; + $teamDeveloperAndProjectCOwnerEmail = 'teamdeveloperandprojectc-' . ID::unique() . '-owner@localhost.test'; + $teamDeveloperAndProjectCOwnerName = 'Team Developer and Project C - owner'; + $projectDOwnerEmail = 'projectd-' . ID::unique() . '-owner@localhost.test'; + $projectDOwnerName = 'Project D - owner'; + $projectEOwnerEmail = 'projecte-' . ID::unique() . '-owner@localhost.test'; + $projectEOwnerName = 'Project E - owner'; + $this->setupUserMembership([ + 'teamId' => $teamId, + 'email' => $teamOwnerEmail, + 'name' => $teamOwnerName, + 'roles' => ["owner"], + ]); + $this->setupUserMembership([ + 'teamId' => $teamId, + 'email' => $teamDeveloperAndProjectCOwnerEmail, + 'name' => $teamDeveloperAndProjectCOwnerName, + 'roles' => ["developer", "project-$projectIdC-owner"], + ]); + $this->setupUserMembership([ + 'teamId' => $teamId, + 'email' => $projectDOwnerEmail, + 'name' => $projectDOwnerName, + 'roles' => ["project-$projectIdD-owner"], + ]); + $this->setupUserMembership([ + 'teamId' => $teamId, + 'email' => $projectEOwnerEmail, + 'name' => $projectEOwnerName, + 'roles' => ["project-$projectIdE-owner"], + ]); + + $testCases = [ + ['userEmail' => $teamOwnerEmail, 'accessibleProjectIds' => [$projectIdA], 'inaccessibleProjectIds' => []], + ['userEmail' => $teamDeveloperAndProjectCOwnerEmail, 'accessibleProjectIds' => [$projectIdB, $projectIdC], 'inaccessibleProjectIds' => []], + ['userEmail' => $projectDOwnerEmail, 'accessibleProjectIds' => [$projectIdD], 'inaccessibleProjectIds' => [$projectIdE]], + ['userEmail' => $projectEOwnerEmail, 'accessibleProjectIds' => [$projectIdE], 'inaccessibleProjectIds' => []], + ]; + + foreach ($testCases as $testCase) { + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'email' => $testCase['userEmail'], + 'password' => 'password', + ]); + $token = $session['cookies']['a_session_' . $this->getProject()['$id']]; + + foreach ($testCase['accessibleProjectIds'] as $projectId) { + // Success: User should be able to delete the project they have access to. + $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $projectId, [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $token, + ]); + $this->assertEquals(204, $response['headers']['status-code']); + } + + foreach ($testCase['inaccessibleProjectIds'] as $projectId) { + // Failure: User should not be able to delete the project they do not have access to. + $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $projectId, [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $token, + ]); + + $this->assertEquals(404, $response['headers']['status-code']); + } + } + } }