Populate project-specific roles

This commit is contained in:
Hemachandar
2026-01-30 14:48:07 +05:30
parent a987195f6f
commit acdb895e38
4 changed files with 420 additions and 11 deletions
+28 -1
View File
@@ -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);
@@ -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
}
}
}
}
+102 -10
View File
@@ -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,
];
}
}
@@ -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']);
}
}
}
}