From 8f3472d55b9e857110fc442cf0fbc8acbce601fb Mon Sep 17 00:00:00 2001 From: Hemachandar Date: Wed, 18 Feb 2026 15:58:24 +0530 Subject: [PATCH 1/4] Convert rule domains to lowercase --- src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php | 1 + .../Platform/Modules/Proxy/Http/Rules/Function/Create.php | 1 + .../Platform/Modules/Proxy/Http/Rules/Redirect/Create.php | 1 + src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php | 1 + 4 files changed, 4 insertions(+) diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php index 86b780bde0..6560c53d2f 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php @@ -72,6 +72,7 @@ class Create extends Action public function action(string $domain, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, array $platform, Log $log) { + $domain = \strtolower($domain); $this->validateDomainRestrictions($domain, $platform); // TODO: (@Meldiron) Remove after 1.7.x migration diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php index 5837d80630..1ade97f8b4 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php @@ -77,6 +77,7 @@ class Create extends Action public function action(string $domain, string $functionId, string $branch, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject, array $platform, Log $log) { + $domain = \strtolower($domain); $this->validateDomainRestrictions($domain, $platform); $function = $dbForProject->getDocument('functions', $functionId); diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php index e1dd3de108..bc61ab82d7 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php @@ -80,6 +80,7 @@ class Create extends Action public function action(string $domain, string $url, int $statusCode, string $resourceId, string $resourceType, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject, array $platform, Log $log) { + $domain = \strtolower($domain); $this->validateDomainRestrictions($domain, $platform); $collection = match ($resourceType) { diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php index 20829c1b91..d407db299f 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php @@ -77,6 +77,7 @@ class Create extends Action public function action(string $domain, string $siteId, string $branch, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject, array $platform, Log $log) { + $domain = \strtolower($domain); $this->validateDomainRestrictions($domain, $platform); $site = $dbForProject->getDocument('sites', $siteId); From 702484c27b2dcc231d74c6518ae3b28e91104570 Mon Sep 17 00:00:00 2001 From: Hemachandar Date: Wed, 18 Feb 2026 21:12:56 +0530 Subject: [PATCH 2/4] lowercase only id --- src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php | 3 +-- .../Platform/Modules/Proxy/Http/Rules/Function/Create.php | 3 +-- .../Platform/Modules/Proxy/Http/Rules/Redirect/Create.php | 3 +-- src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php | 3 +-- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php index 6560c53d2f..bfa62ef920 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php @@ -72,11 +72,10 @@ class Create extends Action public function action(string $domain, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, array $platform, Log $log) { - $domain = \strtolower($domain); $this->validateDomainRestrictions($domain, $platform); // TODO: (@Meldiron) Remove after 1.7.x migration - $ruleId = System::getEnv('_APP_RULES_FORMAT') === 'md5' ? md5($domain) : ID::unique(); + $ruleId = System::getEnv('_APP_RULES_FORMAT') === 'md5' ? md5(\strtolower($domain)) : ID::unique(); $status = RULE_STATUS_CREATED; $owner = ''; diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php index 1ade97f8b4..aae20a1b31 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php @@ -77,7 +77,6 @@ class Create extends Action public function action(string $domain, string $functionId, string $branch, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject, array $platform, Log $log) { - $domain = \strtolower($domain); $this->validateDomainRestrictions($domain, $platform); $function = $dbForProject->getDocument('functions', $functionId); @@ -88,7 +87,7 @@ class Create extends Action $deployment = $dbForProject->getDocument('deployments', $function->getAttribute('deploymentId', '')); // TODO: (@Meldiron) Remove after 1.7.x migration - $ruleId = System::getEnv('_APP_RULES_FORMAT') === 'md5' ? md5($domain) : ID::unique(); + $ruleId = System::getEnv('_APP_RULES_FORMAT') === 'md5' ? md5(\strtolower($domain)) : ID::unique(); $status = RULE_STATUS_CREATED; $owner = ''; diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php index bc61ab82d7..31de618fff 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php @@ -80,7 +80,6 @@ class Create extends Action public function action(string $domain, string $url, int $statusCode, string $resourceId, string $resourceType, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject, array $platform, Log $log) { - $domain = \strtolower($domain); $this->validateDomainRestrictions($domain, $platform); $collection = match ($resourceType) { @@ -93,7 +92,7 @@ class Create extends Action } // TODO: (@Meldiron) Remove after 1.7.x migration - $ruleId = System::getEnv('_APP_RULES_FORMAT') === 'md5' ? md5($domain) : ID::unique(); + $ruleId = System::getEnv('_APP_RULES_FORMAT') === 'md5' ? md5(\strtolower($domain)) : ID::unique(); $status = RULE_STATUS_CREATED; $owner = ''; diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php index d407db299f..3a4bc66107 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php @@ -77,7 +77,6 @@ class Create extends Action public function action(string $domain, string $siteId, string $branch, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject, array $platform, Log $log) { - $domain = \strtolower($domain); $this->validateDomainRestrictions($domain, $platform); $site = $dbForProject->getDocument('sites', $siteId); @@ -88,7 +87,7 @@ class Create extends Action $deployment = $dbForProject->getDocument('deployments', $site->getAttribute('deploymentId', '')); // TODO: (@Meldiron) Remove after 1.7.x migration - $ruleId = System::getEnv('_APP_RULES_FORMAT') === 'md5' ? md5($domain) : ID::unique(); + $ruleId = System::getEnv('_APP_RULES_FORMAT') === 'md5' ? md5(\strtolower($domain)) : ID::unique(); $status = RULE_STATUS_CREATED; $owner = ''; From e902d3b6ad2c0176ba098917398b25a5ecb0f54f Mon Sep 17 00:00:00 2001 From: Hemachandar <132386067+hmacr@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:37:01 +0530 Subject: [PATCH 3/4] fix: sanitize branch names for valid domain generation (#11245) * fix: sanitize branch names for valid domain generation Branch names containing invalid domain characters (like '/') were being used directly when creating VCS preview domains, resulting in invalid domains like 'branch-abc/test.appwrite.network'. This adds a Domain helper class that sanitizes branch names by replacing invalid characters with hyphens before generating domains. Co-Authored-By: Claude Opus 4.5 * refactor: use Text constants for branch name sanitization Replace regex with explicit character validation using Utopia Text constants for better readability and maintainability. Co-Authored-By: Claude Opus 4.5 * Move to transformation adapter * lint * tiny * fix test * ut * use Filter * lint * more lint * simplify --------- Co-authored-by: Claude Opus 4.5 --- app/controllers/api/vcs.php | 14 +-- src/Appwrite/Filter/BranchDomain.php | 96 ++++++++++++++++ src/Appwrite/Filter/Filter.php | 8 ++ .../Platform/Modules/Compute/Base.php | 14 +-- .../Modules/Functions/Workers/Builds.php | 15 ++- tests/unit/Filter/BranchDomainTest.php | 107 ++++++++++++++++++ 6 files changed, 232 insertions(+), 22 deletions(-) create mode 100644 src/Appwrite/Filter/BranchDomain.php create mode 100644 src/Appwrite/Filter/Filter.php create mode 100644 tests/unit/Filter/BranchDomainTest.php diff --git a/app/controllers/api/vcs.php b/app/controllers/api/vcs.php index 9f814afbd3..67cd8d8ea8 100644 --- a/app/controllers/api/vcs.php +++ b/app/controllers/api/vcs.php @@ -2,6 +2,7 @@ use Appwrite\Event\Build; use Appwrite\Extend\Exception; +use Appwrite\Filter\BranchDomain as BranchDomainFilter; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; @@ -316,13 +317,12 @@ $createGitDeployments = function (GitHub $github, string $providerInstallationId // VCS branch preview if (!empty($providerBranch)) { - $branchPrefix = substr($providerBranch, 0, 16); - if (strlen($providerBranch) > 16) { - $remainingChars = substr($providerBranch, 16); - $branchPrefix .= '-' . substr(hash('sha256', $remainingChars), 0, 7); - } - $resourceProjectHash = substr(hash('sha256', $resource->getId() . $project->getId()), 0, 7); - $domain = "branch-{$branchPrefix}-{$resourceProjectHash}.{$sitesDomain}"; + $domain = (new BranchDomainFilter())->apply([ + 'branch' => $providerBranch, + 'resourceId' => $resource->getId(), + 'projectId' => $project->getId(), + 'sitesDomain' => $sitesDomain, + ]); $ruleId = md5($domain); try { $authorization->skip( diff --git a/src/Appwrite/Filter/BranchDomain.php b/src/Appwrite/Filter/BranchDomain.php new file mode 100644 index 0000000000..5a43037ff2 --- /dev/null +++ b/src/Appwrite/Filter/BranchDomain.php @@ -0,0 +1,96 @@ +generateBranchPrefix($branch); + $resourceProjectHash = substr(hash('sha256', $resourceId . $projectId), 0, self::HASH_SUFFIX_LENGTH); + $domain = \strtolower("branch-{$branchPrefix}-{$resourceProjectHash}.{$sitesDomain}"); + return $domain; + } + + /** + * Generate a branch prefix for domain name from a branch name. + * Takes up to 16 characters, sanitizes them for domain use, + * and appends a hash suffix if the branch name is longer than 16 characters. + * + * @param string $branch The branch name + * @return string The branch prefix for domain name + */ + private function generateBranchPrefix(string $branch): string + { + $branchPrefix = substr($branch, 0, self::BRANCH_PREFIX_MAX_LENGTH); + $branchPrefix = $this->sanitizeBranchName($branchPrefix); + + if (strlen($branch) > self::BRANCH_PREFIX_MAX_LENGTH) { + $remainingChars = substr($branch, self::BRANCH_PREFIX_MAX_LENGTH); + $branchPrefix .= '-' . substr(hash('sha256', $remainingChars), 0, self::HASH_SUFFIX_LENGTH); + } + + return $branchPrefix; + } + + /** + * Sanitize a branch name for use in a domain name. + * Replaces any characters that are not alphanumeric or hyphens with hyphens, + * and removes leading/trailing hyphens. + * + * @param string $branch The branch name to sanitize + * @return string The sanitized branch name + */ + private function sanitizeBranchName(string $branch): string + { + $allowedChars = array_merge( + Text::NUMBERS, + Text::ALPHABET_UPPER, + Text::ALPHABET_LOWER, + ['-'] + ); + $allowedCharsFlip = array_flip($allowedChars); + + $sanitized = ''; + for ($i = 0; $i < \strlen($branch); $i++) { + $char = $branch[$i]; + + if (isset($allowedCharsFlip[$char])) { + $sanitized .= $char; + } else { + // Prevents two -- or more in a row + if (strlen($sanitized) > 0 && $sanitized[strlen($sanitized) - 1] !== '-') { + $sanitized .= '-'; + } + } + } + + return trim($sanitized, '-'); + } +} diff --git a/src/Appwrite/Filter/Filter.php b/src/Appwrite/Filter/Filter.php new file mode 100644 index 0000000000..10663fa571 --- /dev/null +++ b/src/Appwrite/Filter/Filter.php @@ -0,0 +1,8 @@ + 16) { - $remainingChars = substr($providerBranch, 16); - $branchPrefix .= '-' . substr(hash('sha256', $remainingChars), 0, 7); - } - $resourceProjectHash = substr(hash('sha256', $site->getId() . $project->getId()), 0, 7); - $domain = "branch-{$branchPrefix}-{$resourceProjectHash}.{$sitesDomain}"; + $domain = (new BranchDomainFilter())->apply([ + 'branch' => $providerBranch, + 'resourceId' => $site->getId(), + 'projectId' => $project->getId(), + 'sitesDomain' => $sitesDomain, + ]); $ruleId = md5($domain); try { $authorization->skip( diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php index db5a0532f1..09a70bb71d 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Builds.php @@ -9,6 +9,7 @@ use Appwrite\Event\Realtime; use Appwrite\Event\Screenshot; use Appwrite\Event\StatsUsage; use Appwrite\Event\Webhook; +use Appwrite\Filter\BranchDomain as BranchDomainFilter; use Appwrite\Utopia\Response\Model\Deployment; use Appwrite\Vcs\Comment; use Exception; @@ -1027,14 +1028,12 @@ class Builds extends Action // VCS branch $branchName = $deployment->getAttribute('providerBranch'); if (!empty($branchName)) { - $sitesDomain = $platform['sitesDomain']; - $branchPrefix = substr($branchName, 0, 16); - if (strlen($branchName) > 16) { - $remainingChars = substr($branchName, 16); - $branchPrefix .= '-' . substr(hash('sha256', $remainingChars), 0, 7); - } - $resourceProjectHash = substr(hash('sha256', $resource->getId() . $project->getId()), 0, 7); - $domain = "branch-{$branchPrefix}-{$resourceProjectHash}.{$sitesDomain}"; + $domain = (new BranchDomainFilter())->apply([ + 'branch' => $branchName, + 'resourceId' => $resource->getId(), + 'projectId' => $project->getId(), + 'sitesDomain' => $platform['sitesDomain'], + ]); $ruleId = md5($domain); try { diff --git a/tests/unit/Filter/BranchDomainTest.php b/tests/unit/Filter/BranchDomainTest.php new file mode 100644 index 0000000000..7ee073e50e --- /dev/null +++ b/tests/unit/Filter/BranchDomainTest.php @@ -0,0 +1,107 @@ +apply([ + 'branch' => 'feature/test', + 'resourceId' => 'site123', + 'projectId' => 'proj456', + 'sitesDomain' => 'appwrite.network' + ]); + $this->assertStringNotContainsString('/', $domain); + $this->assertStringStartsWith('branch-feature-test-', $domain); + $this->assertStringEndsWith('.appwrite.network', $domain); + + // Branch domain consistency + $domain2 = $filter->apply([ + 'branch' => 'feature/test', + 'resourceId' => 'site123', + 'projectId' => 'proj456', + 'sitesDomain' => 'appwrite.network' + ]); + $this->assertEquals($domain, $domain); + + // Different resources should produce different domains + $domain2 = $filter->apply([ + 'branch' => 'feature/test', + 'resourceId' => 'site789', + 'projectId' => 'proj456', + 'sitesDomain' => 'appwrite.network' + ]); + $this->assertNotEquals($domain, $domain2); + + // Different projects should produce different domains + $domain2 = $filter->apply([ + 'branch' => 'feature/test', + 'resourceId' => 'site123', + 'projectId' => 'proj789', + 'sitesDomain' => 'appwrite.network' + ]); + $this->assertNotEquals($domain, $domain2); + + // Some real-world branch names + $domain = $filter->apply([ + 'branch' => 'feature/SER-1234', + 'resourceId' => 'site123', + 'projectId' => 'proj456', + 'sitesDomain' => 'appwrite.network' + ]); + $this->assertStringStartsWith('branch-feature-ser-1234-', $domain); + $this->assertStringEndsWith('.appwrite.network', $domain); + + $domain = $filter->apply([ + 'branch' => 'bugfix/fix-login', + 'resourceId' => 'site123', + 'projectId' => 'proj456', + 'sitesDomain' => 'appwrite.network' + ]); + $this->assertStringStartsWith('branch-bugfix-fix-login-', $domain); + $this->assertStringEndsWith('.appwrite.network', $domain); + + $domain = $filter->apply([ + 'branch' => 'hotfix/v1.2.3', + 'resourceId' => 'site123', + 'projectId' => 'proj456', + 'sitesDomain' => 'appwrite.network' + ]); + $this->assertStringStartsWith('branch-hotfix-v1-2-3-', $domain); + $this->assertStringEndsWith('.appwrite.network', $domain); + + $domain = $filter->apply([ + 'branch' => 'release/2024.01', + 'resourceId' => 'site123', + 'projectId' => 'proj456', + 'sitesDomain' => 'appwrite.network' + ]); + $this->assertStringStartsWith('branch-release-2024-01-', $domain); + $this->assertStringEndsWith('.appwrite.network', $domain); + + $domain = $filter->apply([ + 'branch' => 'user/john/experiment', + 'resourceId' => 'site123', + 'projectId' => 'proj456', + 'sitesDomain' => 'appwrite.network' + ]); + $this->assertStringStartsWith('branch-user-john-experi-', $domain); + $this->assertStringEndsWith('.appwrite.network', $domain); + + $domain = $filter->apply([ + 'branch' => 'dependabot/npm_and_yarn/lodash-4.17.21', + 'resourceId' => 'site123', + 'projectId' => 'proj456', + 'sitesDomain' => 'appwrite.network' + ]); + $this->assertStringStartsWith('branch-dependabot-npm-a-', $domain); + $this->assertStringEndsWith('.appwrite.network', $domain); + } +} From 14e79f34dba5d3a769f57543b47fc004a8dc289c Mon Sep 17 00:00:00 2001 From: Hemachandar <132386067+hmacr@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:48:39 +0530 Subject: [PATCH 4/4] Populate project-specific roles (#11209) * Populate project-specific roles * better comments * simplify * privileged user * update permissions * feedback * lint * only read + cleanup * fix role length * scopes * not api-key * cl-ignore group * rename * ciIgnore * base scope * change base scope * fix projects * copilot suggestion --- app/controllers/api/teams.php | 4 +- app/controllers/shared/api.php | 55 ++- .../Platform/Modules/Compute/Base.php | 19 +- .../Modules/Projects/Http/Projects/Action.php | 15 +- src/Appwrite/Platform/Permission.php | 37 ++ .../Utopia/Database/Documents/User.php | 2 +- .../e2e/Services/GraphQL/TeamsServerTest.php | 2 +- tests/e2e/Services/Projects/ProjectsBase.php | 151 ++++++- .../Projects/ProjectsConsoleClientTest.php | 416 ++++++++++++++++++ .../Utopia/Database/Documents/UserTest.php | 10 +- 10 files changed, 656 insertions(+), 55 deletions(-) create mode 100644 src/Appwrite/Platform/Permission.php diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index bf708a06f8..a674cdc40f 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -477,7 +477,7 @@ Http::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', [], 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(maxLength: 81), 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']) // For project-specific permissions, roles will be in the format `project--`. Template takes 9 characters, `projectId` and `role` can be upto 36 characters. In total, 81 characters. ->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') @@ -1072,7 +1072,7 @@ Http::patch('/v1/teams/:teamId/memberships/:membershipId') )) ->param('teamId', '', new UID(), 'Team ID.') ->param('membershipId', '', new UID(), 'Membership ID.') - ->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']) + ->param('roles', [], new ArrayList(new Key(maxLength: 81), 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']) // For project-specific permissions, roles will be in the format `project--`. Template takes 9 characters, `projectId` and `role` can be upto 36 characters. In total, 81 characters. ->inject('request') ->inject('response') ->inject('user') diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 2c0c61332c..2afcd77a6f 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -295,12 +295,40 @@ Http::init() throw new Exception(Exception::USER_UNAUTHORIZED); } - $scopes = []; // Reset scope if admin - foreach ($adminRoles as $role) { - $scopes = \array_merge($scopes, $roles[$role]['scopes']); + $projectId = $project->getId(); + if ($projectId === 'console' && str_starts_with($route->getPath(), '/v1/projects/:projectId')) { + $uri = $request->getURI(); + $projectId = explode('/', $uri)[3]; } - $authorization->setDefaultStatus(false); // Cancel security segmentation for admin users. + // Base scopes for admin users to allow listing teams and projects. + // Useful for those who have project-specific roles but don't have team-wide role. + $scopes = ['teams.read', 'projects.read']; + foreach ($adminRoles as $adminRole) { + $isTeamWideRole = !str_starts_with($adminRole, 'project-'); + $isProjectSpecificRole = $projectId !== 'console' && str_starts_with($adminRole, 'project-' . $projectId); + + if ($isTeamWideRole || $isProjectSpecificRole) { + $role = match (str_starts_with($adminRole, 'project-')) { + true => substr($adminRole, strrpos($adminRole, '-') + 1), + false => $adminRole, + }; + $roleScopes = $roles[$role]['scopes'] ?? []; + $scopes = \array_merge($scopes, $roleScopes); + $authorization->addRole($role); + } + } + + /** + * For console projects resource, we use platform DB. + * Enabling authorization restricts admin user to the projects they have access to. + */ + if ($project->getId() === 'console' && ($route->getPath() === '/v1/projects' || $route->getPath() === '/v1/projects/:projectId')) { + $authorization->setDefaultStatus(true); + } else { + // Otherwise, disable authorization checks. + $authorization->setDefaultStatus(false); + } } $scopes = \array_unique($scopes); @@ -310,6 +338,21 @@ Http::init() $authorization->addRole($authRole); } + /** + * We disable authorization checks above to ensure other endpoints (list teams, members, etc.) will continue working. + * But, for actions on resources (sites, functions, etc.) in a non-console project, we explicitly check + * whether the admin user has necessary permission on the project (sites, functions, etc. don't have permissions associated to them). + */ + if (empty($apiKey) && $project->getId() !== 'console' && $mode === APP_MODE_ADMIN) { + $input = new Input(Database::PERMISSION_READ, $project->getPermissionsByType(Database::PERMISSION_READ)); + $initialStatus = $authorization->getStatus(); + $authorization->enable(); + if (!$authorization->isValid($input)) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + $authorization->setStatus($initialStatus); + } + // Step 6: Update project and user last activity if (!$project->isEmpty() && $project->getId() !== 'console') { $accessedAt = $project->getAttribute('accessedAt', 0); @@ -324,10 +367,10 @@ Http::init() if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_USER_ACCESS)) > $accessedAt) { $user->setAttribute('accessedAt', DateTime::now()); - if (APP_MODE_ADMIN !== $mode) { + if ($project->getId() !== 'console' && APP_MODE_ADMIN !== $mode) { $dbForProject->updateDocument('users', $user->getId(), $user); } else { - $dbForPlatform->updateDocument('users', $user->getId(), $user); + $authorization->skip(fn () => $dbForPlatform->updateDocument('users', $user->getId(), $user)); } } } diff --git a/src/Appwrite/Platform/Modules/Compute/Base.php b/src/Appwrite/Platform/Modules/Compute/Base.php index 5811179952..08b4bbced8 100644 --- a/src/Appwrite/Platform/Modules/Compute/Base.php +++ b/src/Appwrite/Platform/Modules/Compute/Base.php @@ -7,6 +7,7 @@ use Appwrite\Extend\Exception; use Appwrite\Filter\BranchDomain as BranchDomainFilter; use Appwrite\Platform\Action; use Appwrite\Platform\Modules\Compute\Validator\Specification as SpecificationValidator; +use Appwrite\Platform\Permission as AppwritePermission; use Utopia\Config\Config; use Utopia\Database\Database; use Utopia\Database\Document; @@ -23,23 +24,7 @@ use Utopia\VCS\Exception\RepositoryNotFound; class Base extends Action { - /** - * Permissions for resources in this project. - * - * @param string $teamId - * @param string $projectId - * @return string[] - */ - protected function getPermissions(string $teamId, string $projectId): array - { - return [ - 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')), - ]; - } + use AppwritePermission; /** * Get default specification based on plan and available specifications. diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Action.php b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Action.php index 1a3be1e783..db8630ba78 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Projects/Action.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Projects/Action.php @@ -3,20 +3,9 @@ namespace Appwrite\Platform\Modules\Projects\Http\Projects; use Appwrite\Platform\Action as AppwriteAction; -use Utopia\Database\Helpers\ID; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; +use Appwrite\Platform\Permission as AppwritePermission; class Action extends AppwriteAction { - protected function getPermissions(string $teamId, string $projectId): array - { - return [ - 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')), - ]; - } + use AppwritePermission; } diff --git a/src/Appwrite/Platform/Permission.php b/src/Appwrite/Platform/Permission.php new file mode 100644 index 0000000000..81dd6df164 --- /dev/null +++ b/src/Appwrite/Platform/Permission.php @@ -0,0 +1,37 @@ +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(); diff --git a/tests/e2e/Services/GraphQL/TeamsServerTest.php b/tests/e2e/Services/GraphQL/TeamsServerTest.php index d99ae99cf0..3bf2bfd158 100644 --- a/tests/e2e/Services/GraphQL/TeamsServerTest.php +++ b/tests/e2e/Services/GraphQL/TeamsServerTest.php @@ -297,7 +297,7 @@ class TeamsServerTest extends Scope $this->assertEquals(204, $team['headers']['status-code']); } - /** @group cl-ignore */ + /** @group ciIgnore */ public function testDeleteTeam() { $team = $this->testCreateTeam(); diff --git a/tests/e2e/Services/Projects/ProjectsBase.php b/tests/e2e/Services/Projects/ProjectsBase.php index 0d1d6a5a44..28e22ed432 100644 --- a/tests/e2e/Services/Projects/ProjectsBase.php +++ b/tests/e2e/Services/Projects/ProjectsBase.php @@ -4,27 +4,32 @@ namespace Tests\E2E\Services\Projects; use Tests\E2E\Client; use Utopia\Database\Helpers\ID; +use Utopia\Database\Helpers\Role; 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 +51,130 @@ 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, + ]; + } + + protected function updateMembershipRole(string $teamId, string $membershipId, array $roles): void + { + $response = $this->client->call(Client::METHOD_PATCH, '/teams/' . $teamId . '/memberships/' . $membershipId, array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'roles' => $roles, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + } + + protected function setupFunction(string $projectId, string $functionId, string $token): void + { + $function = $this->client->call(Client::METHOD_POST, '/functions', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-mode' => 'admin', + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $token, + ]), [ + 'functionId' => $functionId, + 'name' => 'Test function', + 'execute' => [Role::any()->toString()], + 'runtime' => 'node-22', + 'entrypoint' => 'index.js', + 'events' => [ + 'users.*.create', + 'users.*.delete', + ], + 'schedule' => '0 0 1 1 *', + 'timeout' => 10, + ]); + $this->assertEquals(201, $function['headers']['status-code']); + $this->assertNotEmpty($function['body']['$id']); + } } diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 42c1a80eaf..2909ba47f4 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -6181,4 +6181,420 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(204, $response['headers']['status-code']); } + + /** + * @group ciIgnore + */ + public function testProjectSpecificPermissionsForListProjects(): void + { + $teamId = ID::unique(); + $projectIdA = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'Project Test A', + ], $teamId); + $projectIdB = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'Project Test B', + ], $teamId, false); + + $testUserEmail = 'test-' . ID::unique() . '@localhost.test'; + $testUserName = 'Test User'; + + [ 'membershipId' => $testUserMembershipId ] = $this->setupUserMembership([ + 'teamId' => $teamId, + 'email' => $testUserEmail, + 'name' => $testUserName, + 'roles' => ["owner"], + ]); + + $testCases = [ + [ + 'roles' => ["owner"], + 'successProjectIds' => [$projectIdA, $projectIdB], + ], + [ + 'roles' => ["developer"], + 'successProjectIds' => [$projectIdA, $projectIdB], + ], + [ + 'roles' => ["project-$projectIdA-owner"], + 'successProjectIds' => [$projectIdA], + ], + [ + 'roles' => ["project-$projectIdB-owner"], + 'successProjectIds' => [$projectIdB], + ], + [ + 'roles' => ["project-$projectIdA-developer"], + 'successProjectIds' => [$projectIdA], + ], + [ + 'roles' => ["project-$projectIdB-developer"], + 'successProjectIds' => [$projectIdB], + ], + [ + 'roles' => ["developer", "project-$projectIdA-owner"], + 'successProjectIds' => [$projectIdA, $projectIdB], + ] + ]; + + // Setup session + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'email' => $testUserEmail, + 'password' => 'password', + ]); + $token = $session['cookies']['a_session_' . $this->getProject()['$id']]; + + foreach ($testCases as $testCase) { + $this->updateMembershipRole($teamId, $testUserMembershipId, $testCase['roles']); + + $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['successProjectIds']), $response['body']['projects']); + + $returnedProjectIds = \array_column($response['body']['projects'], '$id'); + foreach ($testCase['successProjectIds'] as $projectId) { + $this->assertContains($projectId, $returnedProjectIds); + } + } + } + + /** + * @group ciIgnore + */ + public function testProjectSpecificPermissionsForUpdateProject(): void + { + $teamId = ID::unique(); + $projectIdA = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'Project Test A', + ], $teamId); + $projectIdB = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'Project Test B', + ], $teamId, false); + + $testUserEmail = 'test-' . ID::unique() . '@localhost.test'; + $testUserName = 'Test User'; + + [ 'membershipId' => $testUserMembershipId ] = $this->setupUserMembership([ + 'teamId' => $teamId, + 'email' => $testUserEmail, + 'name' => $testUserName, + 'roles' => ["owner"], + ]); + + $testCases = [ + [ + 'roles' => ["owner"], + 'successProjectIds' => [$projectIdA, $projectIdB], + 'failureProjectIds' => [], + ], + [ + 'roles' => ["developer"], + 'successProjectIds' => [$projectIdA, $projectIdB], + 'failureProjectIds' => [], + ], + [ + 'roles' => ["project-$projectIdA-owner"], + 'successProjectIds' => [$projectIdA], + 'failureProjectIds' => [$projectIdB], + ], + [ + 'roles' => ["project-$projectIdB-owner"], + 'successProjectIds' => [$projectIdB], + 'failureProjectIds' => [$projectIdA], + ], + [ + 'roles' => ["project-$projectIdA-developer"], + 'successProjectIds' => [$projectIdA], + 'failureProjectIds' => [$projectIdB], + ], + [ + 'roles' => ["project-$projectIdB-developer"], + 'successProjectIds' => [$projectIdB], + 'failureProjectIds' => [$projectIdA], + ], + [ + 'roles' => ["developer", "project-$projectIdA-owner"], + 'successProjectIds' => [$projectIdA, $projectIdB], + 'failureProjectIds' => [], + ] + ]; + + // Setup session + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'email' => $testUserEmail, + 'password' => 'password', + ]); + $token = $session['cookies']['a_session_' . $this->getProject()['$id']]; + + foreach ($testCases as $testCase) { + $this->updateMembershipRole($teamId, $testUserMembershipId, $testCase['roles']); + + foreach ($testCase['successProjectIds'] 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['failureProjectIds'] 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->assertTrue($response['headers']['status-code'] === 401 || $response['headers']['status-code'] === 404); + } + } + } + + /** + * @group ciIgnore + */ + public function testProjectSpecificPermissionsForDeleteProject(): void + { + $teamId = ID::unique(); + $projectIdA = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'Project Test A', + ], $teamId); + $projectIdB = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'Project Test B', + ], $teamId, false); + $projectIdC = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'Project Test C', + ], $teamId, false); + $projectIdD = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'Project Test D', + ], $teamId, false); + $projectIdE = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'Project Test E', + ], $teamId, false); + + $testUserEmail = 'test-' . ID::unique() . '@localhost.test'; + $testUserName = 'Test User'; + + [ 'membershipId' => $testUserMembershipId ] = $this->setupUserMembership([ + 'teamId' => $teamId, + 'email' => $testUserEmail, + 'name' => $testUserName, + 'roles' => ["owner"], + ]); + + $testCases = [ + [ + 'roles' => ["owner"], + 'successProjectIds' => [$projectIdA], + 'failureProjectIds' => [], + ], + [ + 'roles' => ["developer"], + 'successProjectIds' => [$projectIdB, $projectIdC], + 'failureProjectIds' => [], + ], + [ + 'roles' => ["project-$projectIdD-owner"], + 'successProjectIds' => [$projectIdD], + 'failureProjectIds' => [$projectIdE], + ], + [ + 'roles' => ["project-$projectIdE-owner"], + 'successProjectIds' => [$projectIdE], + 'failureProjectIds' => [], + ], + ]; + + // Setup session + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'email' => $testUserEmail, + 'password' => 'password', + ]); + $token = $session['cookies']['a_session_' . $this->getProject()['$id']]; + + foreach ($testCases as $testCase) { + $this->updateMembershipRole($teamId, $testUserMembershipId, $testCase['roles']); + + foreach ($testCase['successProjectIds'] 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['failureProjectIds'] 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->assertTrue($response['headers']['status-code'] === 401 || $response['headers']['status-code'] === 404); + } + } + } + + /** + * @group ciIgnore + * Test project specific permissions for project resources, in this case 'function variables'. + */ + public function testProjectSpecificPermissionsForProjectResources(): void + { + $teamId = ID::unique(); + $projectIdA = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'Project Test A', + ], $teamId); + $projectIdB = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'Project Test B', + ], $teamId, false); + + $testUserEmail = 'test-' . ID::unique() . '@localhost.test'; + $testUserName = 'Test User'; + + [ 'membershipId' => $testUserMembershipId ] = $this->setupUserMembership([ + 'teamId' => $teamId, + 'email' => $testUserEmail, + 'name' => $testUserName, + 'roles' => ["owner"], + ]); + + $testCases = [ + [ + 'roles' => ["owner"], + 'successProjectIds' => [$projectIdA, $projectIdB], + 'failureProjectIds' => [], + ], + [ + 'roles' => ["developer"], + 'successProjectIds' => [$projectIdA, $projectIdB], + 'failureProjectIds' => [], + ], + [ + 'roles' => ["project-$projectIdA-owner"], + 'successProjectIds' => [$projectIdA], + 'failureProjectIds' => [$projectIdB], + ], + [ + 'roles' => ["project-$projectIdB-owner"], + 'successProjectIds' => [$projectIdB], + 'failureProjectIds' => [$projectIdA], + ], + [ + 'roles' => ["project-$projectIdA-developer"], + 'successProjectIds' => [$projectIdA], + 'failureProjectIds' => [$projectIdB], + ], + [ + 'roles' => ["project-$projectIdB-developer"], + 'successProjectIds' => [$projectIdB], + 'failureProjectIds' => [$projectIdA], + ], + [ + 'roles' => ["developer", "project-$projectIdA-owner"], + 'successProjectIds' => [$projectIdA, $projectIdB], + 'failureProjectIds' => [], + ] + ]; + + // Setup session + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'email' => $testUserEmail, + 'password' => 'password', + ]); + $token = $session['cookies']['a_session_' . $this->getProject()['$id']]; + + // Setup functions + $functionId = ID::unique(); + $this->setupFunction($projectIdA, $functionId, $token); + $this->setupFunction($projectIdB, $functionId, $token); + + foreach ($testCases as $testCase) { + $this->updateMembershipRole($teamId, $testUserMembershipId, $testCase['roles']); + + foreach ($testCase['successProjectIds'] as $projectId) { + $variableId = ID::unique(); + $response = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/variables', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-mode' => 'admin', + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $token, + ], [ + 'key' => 'APP_TEST_' . $variableId, + 'value' => 'TESTINGVALUE', + 'secret' => false + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertEquals('APP_TEST_' . $variableId, $response['body']['key']); + $this->assertEquals('TESTINGVALUE', $response['body']['value']); + } + + foreach ($testCase['failureProjectIds'] as $projectId) { + $variableId = ID::unique(); + $response = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/variables', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-mode' => 'admin', + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $token, + ], [ + 'key' => 'APP_TEST_' . $variableId, + 'value' => 'TESTINGVALUE', + 'secret' => false + ]); + + $this->assertTrue($response['headers']['status-code'] === 401 || $response['headers']['status-code'] === 404); + } + } + } } diff --git a/tests/unit/Utopia/Database/Documents/UserTest.php b/tests/unit/Utopia/Database/Documents/UserTest.php index d5706e7bec..4094b43246 100644 --- a/tests/unit/Utopia/Database/Documents/UserTest.php +++ b/tests/unit/Utopia/Database/Documents/UserTest.php @@ -307,11 +307,11 @@ class UserTest extends TestCase ]); $roles = $user->getRoles($this->getAuthorization()); - $this->assertCount(7, $roles); - $this->assertNotContains(Role::users()->toString(), $roles); - $this->assertNotContains(Role::user(ID::custom('123'))->toString(), $roles); - $this->assertNotContains(Role::users(Roles::DIMENSION_VERIFIED)->toString(), $roles); - $this->assertNotContains(Role::user(ID::custom('123'), Roles::DIMENSION_VERIFIED)->toString(), $roles); + $this->assertCount(11, $roles); + $this->assertContains(Role::users()->toString(), $roles); + $this->assertContains(Role::user(ID::custom('123'))->toString(), $roles); + $this->assertContains(Role::users(Roles::DIMENSION_VERIFIED)->toString(), $roles); + $this->assertContains(Role::user(ID::custom('123'), Roles::DIMENSION_VERIFIED)->toString(), $roles); $this->assertContains(Role::team(ID::custom('abc'))->toString(), $roles); $this->assertContains(Role::team(ID::custom('abc'), 'administrator')->toString(), $roles); $this->assertContains(Role::team(ID::custom('abc'), 'moderator')->toString(), $roles);