diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index b0493ff38f..0191c60c57 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..b95207414b 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; @@ -100,6 +101,7 @@ App::post('/v1/teams') '$id' => $teamId, '$permissions' => [ Permission::read(Role::team($teamId)), + Permission::read(Role::team($teamId, 'member')), Permission::update(Role::team($teamId, 'owner')), Permission::delete(Role::team($teamId, 'owner')), ], @@ -483,7 +485,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 +1096,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..b73bfc9267 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -244,8 +244,29 @@ App::init() } $scopes = []; // Reset scope if admin - foreach ($adminRoles as $role) { - $scopes = \array_merge($scopes, $roles[$role]['scopes']); + + // Populate scopes from team wide roles + $teamWideRoles = \array_filter($adminRoles, fn ($role) => !str_starts_with($role, "project-")); + if (!empty($teamWideRoles)) { + foreach ($teamWideRoles as $role) { + $scopes = \array_merge($scopes, $roles[$role]['scopes']); + } + } else { + $scopes = \array_merge($scopes, $roles[User::ROLE_USERS]['scopes']); + } + + // Populate scopes from project-specific roles + if ($project->getId() !== 'console') { + $projectId = $project->getId(); + $projectRoles = \array_filter($adminRoles, fn ($role) => str_starts_with($role, "project-{$projectId}")); + + foreach ($projectRoles as $role) { + $parts = \explode('-', $role); + if (\count($parts) === 3) { + $role = $parts[2]; + $scopes = \array_merge($scopes, $roles[$role]['scopes']); + } + } } $authorization->setDefaultStatus(false); // Cancel security segmentation for admin users. 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 10c5862285..2f0ac1d715 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": "a074126df965d3c828c0234fa4f38cb8dab836bd" }, "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/a074126df965d3c828c0234fa4f38cb8dab836bd", + "reference": "a074126df965d3c828c0234fa4f38cb8dab836bd", "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-20T08:22:15+00:00" }, { "name": "utopia-php/detector", @@ -4516,16 +4547,16 @@ }, { "name": "utopia-php/migration", - "version": "1.4.4", + "version": "1.4.5", "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "3fe751902012d09d323420cd3523be1ed855e868" + "reference": "ed2b8f3778add4728c518c2a41be4ff2bd68f018" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/3fe751902012d09d323420cd3523be1ed855e868", - "reference": "3fe751902012d09d323420cd3523be1ed855e868", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/ed2b8f3778add4728c518c2a41be4ff2bd68f018", + "reference": "ed2b8f3778add4728c518c2a41be4ff2bd68f018", "shasum": "" }, "require": { @@ -4565,9 +4596,9 @@ ], "support": { "issues": "https://github.com/utopia-php/migration/issues", - "source": "https://github.com/utopia-php/migration/tree/1.4.4" + "source": "https://github.com/utopia-php/migration/tree/1.4.5" }, - "time": "2026-01-16T10:00:07+00:00" + "time": "2026-01-20T06:17:26+00:00" }, { "name": "utopia-php/mongo", @@ -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": { @@ -9012,5 +9052,5 @@ "platform-overrides": { "php": "8.3" }, - "plugin-api-version": "2.2.0" + "plugin-api-version": "2.6.0" } diff --git a/src/Appwrite/Auth/Validator/Role.php b/src/Appwrite/Auth/Validator/Role.php new file mode 100644 index 0000000000..8ab70aeea8 --- /dev/null +++ b/src/Appwrite/Auth/Validator/Role.php @@ -0,0 +1,84 @@ +roles = $roles; + } + + /** + * Get Description + * + * Returns validator description + * + * @return string + */ + public function getDescription(): string + { + return 'Value must be one of (' . \implode(', ', $this->roles) . ' or of 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; + } + + // TODO: Implement role validation + + return true; + } +} \ No newline at end of file diff --git a/src/Appwrite/Utopia/Database/Documents/User.php b/src/Appwrite/Utopia/Database/Documents/User.php index cbd22aaee5..718bed866e 100644 --- a/src/Appwrite/Utopia/Database/Documents/User.php +++ b/src/Appwrite/Utopia/Database/Documents/User.php @@ -60,17 +60,35 @@ 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(); + $nodeRoles = $node['roles'] ?? []; - if (isset($node['roles'])) { - foreach ($node['roles'] as $nodeRole) { // Set all team roles - $roles[] = Role::team($node['teamId'], $nodeRole)->toString(); + // Add role for this membership. + $roles[] = Role::member($node['$id'])->toString(); + + // Add all roles in the team. + $allRoles = \array_map(fn ($role) => Role::team($node['teamId'], $role)->toString(), $nodeRoles); + $roles = \array_merge($roles, $allRoles); + + // Add base team-wide role. + $teamWideRoles = \array_filter($nodeRoles, fn ($role) => !str_starts_with($role, "project-")); + if (!empty($teamWideRoles)) { + $roles[] = Role::team($node['teamId'])->toString(); + } else { + $roles[] = Role::team($node['teamId'], self::ROLE_MEMBER)->toString(); + } + + // Add base project-wide roles. + $projectRoles = \array_filter($nodeRoles, fn ($role) => str_starts_with($role, "project-")); + if (!empty($projectRoles)) { + foreach ($projectRoles as $role) { + $parts = \explode('-', $role); + if (\count($parts) === 3) { + $projectId = $parts[1]; + $roles[] = Role::team($node['teamId'], "project-{$projectId}")->toString(); // Add base project-wide role } } } 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..c38bed9e7a 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 testPerProjectPermissionsForListProjects(): 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' => ['member', Role::project($projectIdA, 'owner')->toString()], + ]); + $this->setupUserMembership([ + 'teamId' => $teamId, + 'email' => $projectBUserEmail, + 'name' => $projectBUserName, + 'roles' => ['member', Role::project($projectIdB, 'owner')->toString()], + ]); + + $users = [ + ['email' => $projectAUserEmail, 'name' => $projectAUserName, 'role' => 'owner', 'projectId' => $projectIdA], + ['email' => $projectBUserEmail, 'name' => $projectBUserName, 'role' => '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 testPerProjectPermissionsForUpdateProject(): 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' => ['member', Role::project($projectIdA, 'owner')->toString()], + ]); + $this->setupUserMembership([ + 'teamId' => $teamId, + 'email' => $projectBUserEmail, + 'name' => $projectBUserName, + 'roles' => ['member', Role::project($projectIdB, 'owner')->toString()], + ]); + + $users = [ + ['email' => $projectAUserEmail, 'name' => $projectAUserName, 'role' => 'owner', 'projectId' => $projectIdA], + ['email' => $projectBUserEmail, 'name' => $projectBUserName, 'role' => '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(404, $response['headers']['status-code']); + } + } + + public function testPerProjectPermissionsForDeleteProject(): 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' => ['member', Role::project($projectIdA, 'owner')->toString()], + ]); + $this->setupUserMembership([ + 'teamId' => $teamId, + 'email' => $projectBUserEmail, + 'name' => $projectBUserName, + 'roles' => ['member', Role::project($projectIdB, 'owner')->toString()], + ]); + + $users = [ + ['email' => $projectAUserEmail, 'name' => $projectAUserName, 'role' => 'owner', 'projectId' => $projectIdA, 'otherProjectId' => $projectIdB], + ['email' => $projectBUserEmail, 'name' => $projectBUserName, 'role' => '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(404, $response['headers']['status-code']); + } + } + } }