Implement project-specific permissions

This commit is contained in:
Hemachandar
2026-01-25 21:55:32 +05:30
parent 0dd2f29a7e
commit 9f5cf5f384
9 changed files with 530 additions and 39 deletions
+14
View File
@@ -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
+3 -3
View File
@@ -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'])
+20 -4
View File
@@ -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);
}
+8 -2
View File
@@ -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"
}
]
}
Generated
+51 -11
View File
@@ -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": {
+96
View File
@@ -0,0 +1,96 @@
<?php
namespace Appwrite\Auth\Validator;
use Utopia\Database\Validator\Roles;
use Utopia\Validator;
class Role extends Validator
{
/**
* @var array
*/
protected array $roles;
/**
* Constructor
*
* Sets the acceptable roles.
*
* @param array $list
* @param string $type of $list items
*/
public function __construct(array $roles)
{
$this->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-<projectId>-<role>")';
}
/**
* 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;
}
}
@@ -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<string>
*/
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
}
}
+103 -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,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,
];
}
}
@@ -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']);
}
}
}
}