From 9f5cf5f384dfd4ab08e0e8dbf8de668b7bdccea2 Mon Sep 17 00:00:00 2001 From: Hemachandar Date: Sun, 25 Jan 2026 21:55:32 +0530 Subject: [PATCH] Implement project-specific permissions --- app/controllers/api/projects.php | 14 ++ app/controllers/api/teams.php | 6 +- app/controllers/shared/api.php | 24 +- composer.json | 10 +- composer.lock | 62 +++++- src/Appwrite/Auth/Validator/Role.php | 96 ++++++++ .../Utopia/Database/Documents/User.php | 34 ++- tests/e2e/Services/Projects/ProjectsBase.php | 113 +++++++++- .../Projects/ProjectsConsoleClientTest.php | 210 ++++++++++++++++++ 9 files changed, 530 insertions(+), 39 deletions(-) create mode 100644 src/Appwrite/Auth/Validator/Role.php diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index b0493ff38f..3f456e8564 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -175,11 +175,18 @@ App::post('/v1/projects') $project = $dbForPlatform->createDocument('projects', new Document([ '$id' => $projectId, '$permissions' => [ + // Team-wide permissions Permission::read(Role::team(ID::custom($teamId))), Permission::update(Role::team(ID::custom($teamId), 'owner')), Permission::update(Role::team(ID::custom($teamId), 'developer')), Permission::delete(Role::team(ID::custom($teamId), 'owner')), Permission::delete(Role::team(ID::custom($teamId), 'developer')), + // Project-specific permissions + Permission::read(Role::team(ID::custom($teamId), "project-$projectId")), + Permission::update(Role::team(ID::custom($teamId), "project-$projectId-owner")), + Permission::update(Role::team(ID::custom($teamId), "project-$projectId-developer")), + Permission::delete(Role::team(ID::custom($teamId), "project-$projectId-owner")), + Permission::delete(Role::team(ID::custom($teamId), "project-$projectId-developer")), ], 'name' => $name, 'teamInternalId' => $team->getSequence(), @@ -428,11 +435,18 @@ App::patch('/v1/projects/:projectId/team') } $permissions = [ + // Team-wide permissions Permission::read(Role::team(ID::custom($teamId))), Permission::update(Role::team(ID::custom($teamId), 'owner')), Permission::update(Role::team(ID::custom($teamId), 'developer')), Permission::delete(Role::team(ID::custom($teamId), 'owner')), Permission::delete(Role::team(ID::custom($teamId), 'developer')), + // Project-specific permissions + Permission::read(Role::team(ID::custom($teamId), "project-$projectId")), + Permission::update(Role::team(ID::custom($teamId), "project-$projectId-owner")), + Permission::update(Role::team(ID::custom($teamId), "project-$projectId-developer")), + Permission::delete(Role::team(ID::custom($teamId), "project-$projectId-owner")), + Permission::delete(Role::team(ID::custom($teamId), "project-$projectId-developer")), ]; $project diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index 2cee394a9c..120b58f91c 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -2,6 +2,7 @@ use Appwrite\Auth\MFA\Type\TOTP; use Appwrite\Auth\Validator\Phone; +use Appwrite\Auth\Validator\Role as RoleValidator; use Appwrite\Detector\Detector; use Appwrite\Event\Delete; use Appwrite\Event\Event; @@ -58,7 +59,6 @@ use Utopia\Validator\ArrayList; use Utopia\Validator\Assoc; use Utopia\Validator\Boolean; use Utopia\Validator\Text; -use Utopia\Validator\WhiteList; App::post('/v1/teams') ->desc('Create team') @@ -483,7 +483,7 @@ App::post('/v1/teams/:teamId/memberships') $roles = array_filter($roles, function ($role) { return !in_array($role, [User::ROLE_APPS, User::ROLE_GUESTS, User::ROLE_USERS]); }); - return new ArrayList(new WhiteList($roles), APP_LIMIT_ARRAY_PARAMS_SIZE); + return new ArrayList(new RoleValidator($roles), APP_LIMIT_ARRAY_PARAMS_SIZE); } return new ArrayList(new Key(), APP_LIMIT_ARRAY_PARAMS_SIZE); }, 'Array of strings. Use this param to set the user roles in the team. A role can be any string. Learn more about [roles and permissions](https://appwrite.io/docs/permissions). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' roles are allowed, each 32 characters long.', false, ['project']) @@ -1094,7 +1094,7 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId') $roles = array_filter($roles, function ($role) { return !in_array($role, [User::ROLE_APPS, User::ROLE_GUESTS, User::ROLE_USERS]); }); - return new ArrayList(new WhiteList($roles), APP_LIMIT_ARRAY_PARAMS_SIZE); + return new ArrayList(new RoleValidator($roles), APP_LIMIT_ARRAY_PARAMS_SIZE); } return new ArrayList(new Key(), APP_LIMIT_ARRAY_PARAMS_SIZE); }, 'An array of strings. Use this param to set the user\'s roles in the team. A role can be any string. Learn more about [roles and permissions](https://appwrite.io/docs/permissions). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' roles are allowed, each 32 characters long.', false, ['project']) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index fffe544330..c4136cbd57 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -91,6 +91,7 @@ App::init() ->inject('authorization') ->action(function (App $utopia, Request $request, Database $dbForPlatform, Database $dbForProject, Audit $queueForAudits, Document $project, Document $user, ?Document $session, array $servers, string $mode, Document $team, ?Key $apiKey, Authorization $authorization) { $route = $utopia->getRoute(); + $path = $route->getPath(); /** * Handle user authentication and session validation. @@ -243,9 +244,24 @@ App::init() throw new Exception(Exception::USER_UNAUTHORIZED); } - $scopes = []; // Reset scope if admin - foreach ($adminRoles as $role) { - $scopes = \array_merge($scopes, $roles[$role]['scopes']); + $teamWideRoles = \array_filter($adminRoles, fn ($role) => !str_starts_with($role, "project-")); + foreach ($teamWideRoles as $teamRole) { + $scopes = \array_merge($scopes, $roles[$teamRole]['scopes']); + } + + $projectId = $project->getId(); + if ($projectId === 'console') { + // Find the true project-id + if (str_starts_with($path, "/v1/projects/:projectId")) { + $uri = $request->getURI(); + $projectId = explode('/', $uri)[3]; + } + } + + $projectSpecificRoles = \array_filter($adminRoles, fn ($role) => str_starts_with($role, "project-$projectId")); + foreach ($projectSpecificRoles as $projectRole) { + $actualRole = explode('-', $projectRole)[2]; + $scopes = \array_merge($scopes, $roles[$actualRole]['scopes']); } $authorization->setDefaultStatus(false); // Cancel security segmentation for admin users. @@ -254,7 +270,7 @@ App::init() $scopes = \array_unique($scopes); $authorization->addRole($role); - foreach ($user->getRoles($authorization) as $authRole) { + foreach ($user->getRoles($authorization, $project->getId(), $path) as $authRole) { $authorization->addRole($authRole); } diff --git a/composer.json b/composer.json index f5bab03697..7cf759c497 100644 --- a/composer.json +++ b/composer.json @@ -52,7 +52,7 @@ "utopia-php/cache": "0.13.*", "utopia-php/cli": "0.15.*", "utopia-php/config": "1.*", - "utopia-php/database": "4.*", + "utopia-php/database": "dev-ser-541-tag-4.5.2 as 4.0.99", "utopia-php/detector": "0.2.*", "utopia-php/domains": "0.11.*", "utopia-php/emails": "0.6.*", @@ -108,5 +108,11 @@ "php-http/discovery": true, "tbachert/spi": true } - } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/utopia-php/database" + } + ] } diff --git a/composer.lock b/composer.lock index 944750ec74..e15ffaaa25 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "33da844fdf5648d1d1a027dfb6ae42bc", + "content-hash": "6df869889de657693cbd12e2cf4cf781", "packages": [ { "name": "adhocore/jwt", @@ -3899,16 +3899,16 @@ }, { "name": "utopia-php/database", - "version": "4.5.2", + "version": "dev-ser-541-tag-4.5.2", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "8e6a033d4da09a2f2ac1f79fd85fcfa2da018d23" + "reference": "71c0b9c44f7b4d47b3d7517f592374743eb604d6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/8e6a033d4da09a2f2ac1f79fd85fcfa2da018d23", - "reference": "8e6a033d4da09a2f2ac1f79fd85fcfa2da018d23", + "url": "https://api.github.com/repos/utopia-php/database/zipball/71c0b9c44f7b4d47b3d7517f592374743eb604d6", + "reference": "71c0b9c44f7b4d47b3d7517f592374743eb604d6", "shasum": "" }, "require": { @@ -3937,7 +3937,38 @@ "Utopia\\Database\\": "src/Database" } }, - "notification-url": "https://packagist.org/downloads/", + "autoload-dev": { + "psr-4": { + "Tests\\E2E\\": "tests/e2e", + "Tests\\Unit\\": "tests/unit" + } + }, + "scripts": { + "build": [ + "Composer\\Config::disableProcessTimeout", + "docker compose build" + ], + "start": [ + "Composer\\Config::disableProcessTimeout", + "docker compose up -d" + ], + "test": [ + "Composer\\Config::disableProcessTimeout", + "docker compose exec tests vendor/bin/phpunit --configuration phpunit.xml" + ], + "lint": [ + "php -d memory_limit=2G ./vendor/bin/pint --test" + ], + "format": [ + "php -d memory_limit=2G ./vendor/bin/pint" + ], + "check": [ + "./vendor/bin/phpstan analyse --level 7 src tests --memory-limit 2G" + ], + "coverage": [ + "./vendor/bin/coverage-check ./tmp/clover.xml 90" + ] + }, "license": [ "MIT" ], @@ -3950,10 +3981,10 @@ "utopia" ], "support": { - "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/4.5.2" + "source": "https://github.com/utopia-php/database/tree/ser-541-tag-4.5.2", + "issues": "https://github.com/utopia-php/database/issues" }, - "time": "2026-01-15T04:23:30+00:00" + "time": "2026-01-25T15:56:19+00:00" }, { "name": "utopia-php/detector", @@ -8986,9 +9017,18 @@ "time": "2024-03-07T20:33:40+00:00" } ], - "aliases": [], + "aliases": [ + { + "package": "utopia-php/database", + "version": "dev-ser-541-tag-4.5.2", + "alias": "4.0.99", + "alias_normalized": "4.0.99.0" + } + ], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": { + "utopia-php/database": 20 + }, "prefer-stable": false, "prefer-lowest": false, "platform": { diff --git a/src/Appwrite/Auth/Validator/Role.php b/src/Appwrite/Auth/Validator/Role.php new file mode 100644 index 0000000000..2f9e17f4c6 --- /dev/null +++ b/src/Appwrite/Auth/Validator/Role.php @@ -0,0 +1,96 @@ +roles = $roles; + } + + /** + * Get Description + * + * Returns validator description + * + * @return string + */ + public function getDescription(): string + { + return 'Value must be one of (' . \implode(', ', $this->roles) . ' (or) in the format "project--")'; + } + + /** + * Is array + * + * Function will return true if object is array. + * + * @return bool + */ + public function isArray(): bool + { + return false; + } + + /** + * Get Type + * + * Returns validator type. + * + * @return string + */ + public function getType(): string + { + return self::TYPE_STRING; + } + + /** + * Is valid + * + * Validation will pass if $value is in the white list array. + * + * @param mixed $value + * @return bool + */ + public function isValid(mixed $value): bool + { + if (!\is_string($value)) { + return false; + } + + $role = $value; + + if (str_starts_with($value, "project-")) { + $parts = explode("-", $value); + if (\count($parts) !== 3) { + return false; + } + + $role = $parts[2]; + } + + if (!\in_array($role, $this->roles)) { + return false; + } + + return true; + } +} diff --git a/src/Appwrite/Utopia/Database/Documents/User.php b/src/Appwrite/Utopia/Database/Documents/User.php index cbd22aaee5..1821e86f2b 100644 --- a/src/Appwrite/Utopia/Database/Documents/User.php +++ b/src/Appwrite/Utopia/Database/Documents/User.php @@ -7,6 +7,7 @@ use Utopia\Auth\Proofs\Token; use Utopia\Database\DateTime; use Utopia\Database\Document; use Utopia\Database\Helpers\Role; +use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\Roles; class User extends Document @@ -35,7 +36,7 @@ class User extends Document * * @return array */ - public function getRoles($authorization): array + public function getRoles(Authorization $authorization, string $projectId = '', string $path = ''): array { $roles = []; @@ -60,19 +61,34 @@ class User extends Document } foreach ($this->getAttribute('memberships', []) as $node) { - if (!isset($node['confirm']) || !$node['confirm']) { + if (!isset($node['confirm']) || !$node['confirm'] || !isset($node['$id']) || !isset($node['teamId'])) { continue; } - if (isset($node['$id']) && isset($node['teamId'])) { - $roles[] = Role::team($node['teamId'])->toString(); - $roles[] = Role::member($node['$id'])->toString(); + // Role for this membership id. + $roles[] = Role::member($node['$id'])->toString(); - if (isset($node['roles'])) { - foreach ($node['roles'] as $nodeRole) { // Set all team roles - $roles[] = Role::team($node['teamId'], $nodeRole)->toString(); - } + $nodeRoles = $node['roles'] ?? []; + + if ($projectId !== 'console') { + $roles[] = Role::team($node['teamId'])->toString(); // Populate team-wide base role. + } else { + $teamWideRoles = \array_filter($nodeRoles, fn ($role) => !str_starts_with($role, "project-")); + $populateTeamWideRole = !str_starts_with($path, "/v1/projects") || !empty($teamWideRoles); + + if ($populateTeamWideRole) { + $roles[] = Role::team($node['teamId'])->toString(); // Populate team-wide base role. } + + $projectSpecificRoles = \array_filter($nodeRoles, fn ($role) => str_starts_with($role, "project-")); + foreach ($projectSpecificRoles as $projectRole) { + $parts = explode("-", $projectRole); + $roles[] = Role::team($node['teamId'], "$parts[0]-$parts[1]")->toString(); // Populate project-wide base role. + } + } + + foreach ($nodeRoles as $nodeRole) { + $roles[] = Role::team($node['teamId'], $nodeRole)->toString(); // Set all team roles } } diff --git a/tests/e2e/Services/Projects/ProjectsBase.php b/tests/e2e/Services/Projects/ProjectsBase.php index 0d1d6a5a44..89ce886727 100644 --- a/tests/e2e/Services/Projects/ProjectsBase.php +++ b/tests/e2e/Services/Projects/ProjectsBase.php @@ -7,24 +7,28 @@ use Utopia\Database\Helpers\ID; trait ProjectsBase { - protected function setupProject(mixed $params): string + protected function setupProject(mixed $params, string $teamId = null, bool $newTeam = true): string { - $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'teamId' => ID::unique(), - 'name' => 'Project Test', - ]); + if ($newTeam) { + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'teamId' => $teamId ?? ID::unique(), + 'name' => 'Project Test', + ]); - $this->assertEquals(201, $team['headers']['status-code'], 'Setup team failed with status code: ' . $team['headers']['status-code'] . ' and response: ' . json_encode($team['body'], JSON_PRETTY_PRINT)); + $this->assertEquals(201, $team['headers']['status-code'], 'Setup team failed with status code: ' . $team['headers']['status-code'] . ' and response: ' . json_encode($team['body'], JSON_PRETTY_PRINT)); + + $teamId = $team['body']['$id']; + } $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ ...$params, - 'teamId' => $team['body']['$id'], + 'teamId' => $teamId, ]); $this->assertEquals(201, $project['headers']['status-code'], 'Setup project failed with status code: ' . $project['headers']['status-code'] . ' and response: ' . json_encode($project['body'], JSON_PRETTY_PRINT)); @@ -46,4 +50,93 @@ trait ProjectsBase 'secret' => $devKey['body']['secret'], ]; } + + protected function setupUserMembership(mixed $params): array + { + // Create membership + $response = $this->client->call(Client::METHOD_POST, '/teams/' . $params['teamId'] . '/memberships', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'email' => $params['email'], + 'name' => $params['name'], + 'roles' => $params['roles'], + 'url' => 'http://localhost:5000/join-us#title' + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertNotEmpty($response['body']['userId']); + $this->assertEquals($params['name'], $response['body']['userName']); + $this->assertEquals($params['email'], $response['body']['userEmail']); + $this->assertNotEmpty($response['body']['teamId']); + $this->assertCount(count($params['roles']), $response['body']['roles']); + $this->assertEquals(false, $response['body']['confirm']); + + $userId = $response['body']['userId']; + $membershipId = $response['body']['$id']; + + + $lastEmail = $this->getLastEmail(); + $tokens = $this->extractQueryParamsFromEmailLink($lastEmail['html']); + $userId = $tokens['userId']; + $secret = $tokens['secret']; + + // Confirm membership + $response = $this->client->call(Client::METHOD_PATCH, '/teams/' . $params['teamId'] . '/memberships/' . $membershipId . '/status', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'userId' => $userId, + 'secret' => $secret, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertNotEmpty($response['body']['userId']); + $this->assertNotEmpty($response['body']['teamId']); + $this->assertCount(count($params['roles']), $response['body']['roles']); + $this->assertEquals(true, $response['body']['confirm']); + + // Simulate password recovery flow to reset password for the created user (useful when creating session for this user) + $response = $this->client->call(Client::METHOD_POST, '/account/recovery', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'email' => $params['email'], + 'url' => 'http://localhost/recovery', + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertEmpty($response['body']['secret']); + + $lastEmail = $this->getLastEmail(); + $this->assertEquals($params['email'], $lastEmail['to'][0]['address']); + $this->assertEquals($params['name'], $lastEmail['to'][0]['name']); + $this->assertEquals('Password Reset for ' . $this->getProject()['name'], $lastEmail['subject']); + $this->assertStringContainsStringIgnoringCase('Reset your ' . $this->getProject()['name'] . ' password using the link.', $lastEmail['text']); + + $tokens = $this->extractQueryParamsFromEmailLink($lastEmail['html']); + $secret = $tokens['secret']; + + $response = $this->client->call(Client::METHOD_PUT, '/account/recovery', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'userId' => $userId, + 'secret' => $secret, + 'password' => 'password', + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + return [ + 'userId' => $userId, + 'membershipId' => $membershipId, + ]; + } } diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index e31331574f..5390b8b19a 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -5594,4 +5594,214 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(204, $response['headers']['status-code']); } + + public function testProjectSpecificPermissionsForListProjects(): void + { + $teamId = ID::unique(); + $projectIdA = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'Project Test A', + 'region' => System::getEnv('_APP_REGION', 'default') + ], $teamId); + $projectIdB = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'Project Test B', + 'region' => System::getEnv('_APP_REGION', 'default') + ], $teamId, false); + + $projectAUserEmail = 'projecta-' . ID::unique() . '-owner@localhost.test'; + $projectAUserName = 'Project A - owner'; + $projectBUserEmail = 'projectb-' . ID::unique() . '-owner@localhost.test'; + $projectBUserName = 'Project B - owner'; + $this->setupUserMembership([ + 'teamId' => $teamId, + 'email' => $projectAUserEmail, + 'name' => $projectAUserName, + 'roles' => ["project-$projectIdA-owner"], + ]); + $this->setupUserMembership([ + 'teamId' => $teamId, + 'email' => $projectBUserEmail, + 'name' => $projectBUserName, + 'roles' => ["project-$projectIdB-owner"], + ]); + + $users = [ + ['email' => $projectAUserEmail, 'name' => $projectAUserName, 'role' => "project-$projectIdA-owner", 'projectId' => $projectIdA], + ['email' => $projectBUserEmail, 'name' => $projectBUserName, 'role' => "project-$projectIdB-owner", 'projectId' => $projectIdB], + ]; + + foreach ($users as $user) { + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'email' => $user['email'], + 'password' => 'password', + ]); + $token = $session['cookies']['a_session_' . $this->getProject()['$id']]; + + $response = $this->client->call(Client::METHOD_GET, '/projects', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $token, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + $this->assertCount(1, $response['body']['projects']); + $this->assertEquals($user['projectId'], $response['body']['projects'][0]['$id']); + } + } + + public function testProjectSpecificPermissionsForUpdateProject(): void + { + $teamId = ID::unique(); + $projectIdA = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'Project Test A', + 'region' => System::getEnv('_APP_REGION', 'default') + ], $teamId); + $projectIdB = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'Project Test B', + 'region' => System::getEnv('_APP_REGION', 'default') + ], $teamId, false); + + $projectAUserEmail = 'projecta-' . ID::unique() . '-owner@localhost.test'; + $projectAUserName = 'Project A - owner'; + $projectBUserEmail = 'projectb-' . ID::unique() . '-owner@localhost.test'; + $projectBUserName = 'Project B - owner'; + $this->setupUserMembership([ + 'teamId' => $teamId, + 'email' => $projectAUserEmail, + 'name' => $projectAUserName, + 'roles' => ["project-$projectIdA-owner"], + ]); + $this->setupUserMembership([ + 'teamId' => $teamId, + 'email' => $projectBUserEmail, + 'name' => $projectBUserName, + 'roles' => ["project-$projectIdB-owner"], + ]); + + $users = [ + ['email' => $projectAUserEmail, 'name' => $projectAUserName, 'role' => "project-$projectIdA-owner", 'projectId' => $projectIdA], + ['email' => $projectBUserEmail, 'name' => $projectBUserName, 'role' => "project-$projectIdB-owner", 'projectId' => $projectIdB], + ]; + + foreach ($users as $user) { + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'email' => $user['email'], + 'password' => 'password', + ]); + $token = $session['cookies']['a_session_' . $this->getProject()['$id']]; + + $accessibleProjectId = $user['projectId'] === $projectIdA ? $projectIdA : $projectIdB; + $inaccessibleProjectId = $user['projectId'] === $projectIdA ? $projectIdB : $projectIdA; + + $updatedProjectName = 'Updated Project Name ' . ID::unique(); + + // Success: User should be able to update the project they have membership for. + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $accessibleProjectId, [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $token, + ], [ + 'name' => $updatedProjectName, + ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + $this->assertEquals($updatedProjectName, $response['body']['name']); + + // Failure: User should not be able to update the project they do not have membership for. + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $inaccessibleProjectId, [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $token, + ], [ + 'name' => $updatedProjectName, + ]); + + $this->assertEquals(401, $response['headers']['status-code']); + } + } + + public function testProjectSpecificPermissionsForDeleteProject(): void + { + $teamId = ID::unique(); + $projectIdA = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'Project Test A', + 'region' => System::getEnv('_APP_REGION', 'default') + ], $teamId); + $projectIdB = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'Project Test B', + 'region' => System::getEnv('_APP_REGION', 'default') + ], $teamId, false); + + $projectAUserEmail = 'projecta-' . ID::unique() . '-owner@localhost.test'; + $projectAUserName = 'Project A - owner'; + $projectBUserEmail = 'projectb-' . ID::unique() . '-owner@localhost.test'; + $projectBUserName = 'Project B - owner'; + $this->setupUserMembership([ + 'teamId' => $teamId, + 'email' => $projectAUserEmail, + 'name' => $projectAUserName, + 'roles' => ["project-$projectIdA-owner"], + ]); + $this->setupUserMembership([ + 'teamId' => $teamId, + 'email' => $projectBUserEmail, + 'name' => $projectBUserName, + 'roles' => ["project-$projectIdB-owner"], + ]); + + $users = [ + ['email' => $projectAUserEmail, 'name' => $projectAUserName, 'role' => "project-$projectIdA-owner", 'projectId' => $projectIdA, 'otherProjectId' => $projectIdB], + ['email' => $projectBUserEmail, 'name' => $projectBUserName, 'role' => "project-$projectIdB-owner", 'projectId' => $projectIdB], + ]; + + foreach ($users as $user) { + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'email' => $user['email'], + 'password' => 'password', + ]); + $token = $session['cookies']['a_session_' . $this->getProject()['$id']]; + + // Success: User should be able to delete the project they have membership for. + $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $user['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']); + + if (!empty($user['otherProjectId'])) { + // Failure: User should not be able to delete the project they do not have membership for. + $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $user['otherProjectId'], [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $token, + ]); + + $this->assertEquals(401, $response['headers']['status-code']); + } + } + } }