diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 2825ea3a74..6c98f7b5ed 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -265,10 +265,20 @@ App::init() $scopes = []; // Reset scope if admin foreach ($adminRoles as $role) { + if (str_starts_with($role, 'project-')) { + $role = substr($role, strrpos($role, '-') + 1); + } $scopes = \array_merge($scopes, $roles[$role]['scopes']); + $authorization->addRole($role); } - $authorization->setDefaultStatus(false); // Cancel security segmentation for admin users. + // For projects resources, ensure admin user has access to the retrieved project(s). + if ($project->getId() === 'console' && str_starts_with($route->getPath(), '/v1/projects')) { + $authorization->setDefaultStatus(true); + } else { + // Otherwise, disable authorization checks. + $authorization->setDefaultStatus(false); + } } $scopes = \array_unique($scopes); @@ -278,6 +288,23 @@ App::init() $authorization->addRole($authRole); } + // Ensure admin user has access to the non-console project. + if ($project->getId() !== 'console' && $mode === APP_MODE_ADMIN) { + $action = match ($route->getMethod()) { + Request::METHOD_GET => Database::PERMISSION_READ, + Request::METHOD_DELETE => Database::PERMISSION_DELETE, + default => Database::PERMISSION_UPDATE, + }; + $input = new Input($action, $project->getPermissionsByType($action)); + + $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); diff --git a/src/Appwrite/Utopia/Database/Documents/User.php b/src/Appwrite/Utopia/Database/Documents/User.php index cbd22aaee5..69bc4077a9 100644 --- a/src/Appwrite/Utopia/Database/Documents/User.php +++ b/src/Appwrite/Utopia/Database/Documents/User.php @@ -72,6 +72,11 @@ class User extends Document foreach ($node['roles'] as $nodeRole) { // Set all team roles $roles[] = Role::team($node['teamId'], $nodeRole)->toString(); } + + if (str_starts_with($nodeRole, 'project-')) { + $projectBaseRole = substr($nodeRole, 0, strrpos($nodeRole, '-')); + $roles[] = Role::team($node['teamId'], $projectBaseRole)->toString(); // Add base role for project-specific permission + } } } } diff --git a/tests/e2e/Services/Projects/ProjectsBase.php b/tests/e2e/Services/Projects/ProjectsBase.php index 0d1d6a5a44..2a4622e28b 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,92 @@ 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 ff917dea5c..576a97ab0f 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -5594,4 +5594,289 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(204, $response['headers']['status-code']); } + + public function testProjectSpecificPermissionsForListProjects(): void + { + $teamId = ID::unique(); + $projectIdA = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'Project Test A', + 'region' => System::getEnv('_APP_REGION', 'default') + ], $teamId); + $projectIdB = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'Project Test B', + 'region' => System::getEnv('_APP_REGION', 'default') + ], $teamId, false); + + $teamOwnerEmail = 'team-' . ID::unique() . '-owner@localhost.test'; + $teamOwnerName = 'Team - owner'; + $teamDeveloperAndProjectAOwnerEmail = 'teamdeveloperandprojecta-' . ID::unique() . '-owner@localhost.test'; + $teamDeveloperAndProjectAOwnerName = 'Team Developer and Project A - owner'; + $projectAOwnerEmail = 'projecta-' . ID::unique() . '-owner@localhost.test'; + $projectAOwnerName = 'Project A - owner'; + $projectBOwnerEmail = 'projectb-' . ID::unique() . '-owner@localhost.test'; + $projectBOwnerName = 'Project B - owner'; + $this->setupUserMembership([ + 'teamId' => $teamId, + 'email' => $teamOwnerEmail, + 'name' => $teamOwnerName, + 'roles' => ["owner"], + ]); + $this->setupUserMembership([ + 'teamId' => $teamId, + 'email' => $teamDeveloperAndProjectAOwnerEmail, + 'name' => $teamDeveloperAndProjectAOwnerName, + 'roles' => ["developer", "project-$projectIdA-owner"], + ]); + $this->setupUserMembership([ + 'teamId' => $teamId, + 'email' => $projectAOwnerEmail, + 'name' => $projectAOwnerName, + 'roles' => ["project-$projectIdA-owner"], + ]); + $this->setupUserMembership([ + 'teamId' => $teamId, + 'email' => $projectBOwnerEmail, + 'name' => $projectBOwnerName, + 'roles' => ["project-$projectIdB-owner"], + ]); + + $testCases = [ + ['userEmail' => $teamOwnerEmail, 'accessibleProjectIds' => [$projectIdA, $projectIdB]], + ['userEmail' => $teamDeveloperAndProjectAOwnerEmail, 'accessibleProjectIds' => [$projectIdA, $projectIdB]], + ['userEmail' => $projectAOwnerEmail, 'accessibleProjectIds' => [$projectIdA]], + ['userEmail' => $projectBOwnerEmail, 'accessibleProjectIds' => [$projectIdB]], + ]; + + foreach ($testCases as $testCase) { + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'email' => $testCase['userEmail'], + 'password' => 'password', + ]); + $token = $session['cookies']['a_session_' . $this->getProject()['$id']]; + $response = $this->client->call(Client::METHOD_GET, '/projects', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $token, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + $this->assertCount(\count($testCase['accessibleProjectIds']), $response['body']['projects']); + + $returnedProjectIds = \array_column($response['body']['projects'], '$id'); + foreach ($testCase['accessibleProjectIds'] as $projectId) { + $this->assertContains($projectId, $returnedProjectIds); + } + } + } + + public function testProjectSpecificPermissionsForUpdateProjects(): void + { + $teamId = ID::unique(); + $projectIdA = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'Project Test A', + 'region' => System::getEnv('_APP_REGION', 'default') + ], $teamId); + $projectIdB = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'Project Test B', + 'region' => System::getEnv('_APP_REGION', 'default') + ], $teamId, false); + + $teamOwnerEmail = 'team-' . ID::unique() . '-owner@localhost.test'; + $teamOwnerName = 'Team - owner'; + $teamDeveloperAndProjectAOwnerEmail = 'teamdeveloperandprojecta-' . ID::unique() . '-owner@localhost.test'; + $teamDeveloperAndProjectAOwnerName = 'Team Developer and Project A - owner'; + $projectAOwnerEmail = 'projecta-' . ID::unique() . '-owner@localhost.test'; + $projectAOwnerName = 'Project A - owner'; + $projectBOwnerEmail = 'projectb-' . ID::unique() . '-owner@localhost.test'; + $projectBOwnerName = 'Project B - owner'; + $this->setupUserMembership([ + 'teamId' => $teamId, + 'email' => $teamOwnerEmail, + 'name' => $teamOwnerName, + 'roles' => ["owner"], + ]); + $this->setupUserMembership([ + 'teamId' => $teamId, + 'email' => $teamDeveloperAndProjectAOwnerEmail, + 'name' => $teamDeveloperAndProjectAOwnerName, + 'roles' => ["developer", "project-$projectIdA-owner"], + ]); + $this->setupUserMembership([ + 'teamId' => $teamId, + 'email' => $projectAOwnerEmail, + 'name' => $projectAOwnerName, + 'roles' => ["project-$projectIdA-owner"], + ]); + $this->setupUserMembership([ + 'teamId' => $teamId, + 'email' => $projectBOwnerEmail, + 'name' => $projectBOwnerName, + 'roles' => ["project-$projectIdB-owner"], + ]); + + $testCases = [ + ['userEmail' => $teamOwnerEmail, 'accessibleProjectIds' => [$projectIdA, $projectIdB], 'inaccessibleProjectIds' => []], + ['userEmail' => $teamDeveloperAndProjectAOwnerEmail, 'accessibleProjectIds' => [$projectIdA, $projectIdB], 'inaccessibleProjectIds' => []], + ['userEmail' => $projectAOwnerEmail, 'accessibleProjectIds' => [$projectIdA], 'inaccessibleProjectIds' => [$projectIdB]], + ['userEmail' => $projectBOwnerEmail, 'accessibleProjectIds' => [$projectIdB], 'inaccessibleProjectIds' => [$projectIdA]], + ]; + + foreach ($testCases as $testCase) { + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'email' => $testCase['userEmail'], + 'password' => 'password', + ]); + $token = $session['cookies']['a_session_' . $this->getProject()['$id']]; + + foreach ($testCase['accessibleProjectIds'] as $projectId) { + $newProjectName = 'Updated Project Name ' . ID::unique(); + // Success: User should be able to update the project they have access to. + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId, [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $token, + ], [ + 'name' => $newProjectName, + ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + $this->assertEquals($newProjectName, $response['body']['name']); + } + + foreach ($testCase['inaccessibleProjectIds'] as $projectId) { + $newProjectName = 'Updated Project Name ' . ID::unique(); + // Failure: User should not be able to update the project they do not have access to. + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $projectId, [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $token, + ], [ + 'name' => $newProjectName, + ]); + + $this->assertEquals(404, $response['headers']['status-code'], \var_export($testCase, true)); + } + } + } + + public function testProjectSpecificPermissionsForDeleteProjects(): void + { + $teamId = ID::unique(); + $projectIdA = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'Project Test A', + 'region' => System::getEnv('_APP_REGION', 'default') + ], $teamId); + $projectIdB = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'Project Test B', + 'region' => System::getEnv('_APP_REGION', 'default') + ], $teamId, false); + $projectIdC = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'Project Test C', + 'region' => System::getEnv('_APP_REGION', 'default') + ], $teamId, false); + $projectIdD = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'Project Test D', + 'region' => System::getEnv('_APP_REGION', 'default') + ], $teamId, false); + $projectIdE = $this->setupProject([ + 'projectId' => ID::unique(), + 'name' => 'Project Test E', + 'region' => System::getEnv('_APP_REGION', 'default') + ], $teamId, false); + + $teamOwnerEmail = 'team-' . ID::unique() . '-owner@localhost.test'; + $teamOwnerName = 'Team - owner'; + $teamDeveloperAndProjectCOwnerEmail = 'teamdeveloperandprojectc-' . ID::unique() . '-owner@localhost.test'; + $teamDeveloperAndProjectCOwnerName = 'Team Developer and Project C - owner'; + $projectDOwnerEmail = 'projectd-' . ID::unique() . '-owner@localhost.test'; + $projectDOwnerName = 'Project D - owner'; + $projectEOwnerEmail = 'projecte-' . ID::unique() . '-owner@localhost.test'; + $projectEOwnerName = 'Project E - owner'; + $this->setupUserMembership([ + 'teamId' => $teamId, + 'email' => $teamOwnerEmail, + 'name' => $teamOwnerName, + 'roles' => ["owner"], + ]); + $this->setupUserMembership([ + 'teamId' => $teamId, + 'email' => $teamDeveloperAndProjectCOwnerEmail, + 'name' => $teamDeveloperAndProjectCOwnerName, + 'roles' => ["developer", "project-$projectIdC-owner"], + ]); + $this->setupUserMembership([ + 'teamId' => $teamId, + 'email' => $projectDOwnerEmail, + 'name' => $projectDOwnerName, + 'roles' => ["project-$projectIdD-owner"], + ]); + $this->setupUserMembership([ + 'teamId' => $teamId, + 'email' => $projectEOwnerEmail, + 'name' => $projectEOwnerName, + 'roles' => ["project-$projectIdE-owner"], + ]); + + $testCases = [ + ['userEmail' => $teamOwnerEmail, 'accessibleProjectIds' => [$projectIdA], 'inaccessibleProjectIds' => []], + ['userEmail' => $teamDeveloperAndProjectCOwnerEmail, 'accessibleProjectIds' => [$projectIdB, $projectIdC], 'inaccessibleProjectIds' => []], + ['userEmail' => $projectDOwnerEmail, 'accessibleProjectIds' => [$projectIdD], 'inaccessibleProjectIds' => [$projectIdE]], + ['userEmail' => $projectEOwnerEmail, 'accessibleProjectIds' => [$projectIdE], 'inaccessibleProjectIds' => []], + ]; + + foreach ($testCases as $testCase) { + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'email' => $testCase['userEmail'], + 'password' => 'password', + ]); + $token = $session['cookies']['a_session_' . $this->getProject()['$id']]; + + foreach ($testCase['accessibleProjectIds'] as $projectId) { + // Success: User should be able to delete the project they have access to. + $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $projectId, [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $token, + ]); + $this->assertEquals(204, $response['headers']['status-code']); + } + + foreach ($testCase['inaccessibleProjectIds'] as $projectId) { + // Failure: User should not be able to delete the project they do not have access to. + $response = $this->client->call(Client::METHOD_DELETE, '/projects/' . $projectId, [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $token, + ]); + + $this->assertEquals(404, $response['headers']['status-code']); + } + } + } }