diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index 2db063d75a..970ffaefd3 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -169,7 +169,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') @@ -764,7 +764,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/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/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/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/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/Modules/Proxy/Http/Rules/API/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php index 86b780bde0..bfa62ef920 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php @@ -75,7 +75,7 @@ class Create extends Action $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 5837d80630..aae20a1b31 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php @@ -87,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 e1dd3de108..31de618fff 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php @@ -92,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 20829c1b91..3a4bc66107 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php @@ -87,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 = ''; 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/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); + } +} 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);