Merge branch '1.8.x' into ser-633-teams

This commit is contained in:
Hemachandar
2026-02-19 21:13:54 +05:30
19 changed files with 892 additions and 81 deletions
+2 -2
View File
@@ -169,7 +169,7 @@ Http::post('/v1/teams/:teamId/memberships')
->param('email', '', new EmailValidator(), 'Email of the new team member.', true)
->param('userId', '', new UID(), 'ID of the user to be added to a team.', true)
->param('phone', '', new Phone(), 'Phone number. Format this number with a leading \'+\' and a country code, e.g., +16175551212.', true)
->param('roles', [], 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'])
->param('roles', [], new ArrayList(new Key(maxLength: 81), 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']) // For project-specific permissions, roles will be in the format `project-<projectId>-<role>`. Template takes 9 characters, `projectId` and `role` can be upto 36 characters. In total, 81 characters.
->param('url', '', fn ($redirectValidator) => $redirectValidator, 'URL to redirect the user back to your app from the invitation email. This parameter is not required when an API key is supplied. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['redirectValidator']) // TODO add our own built-in confirm page
->param('name', '', new Text(128), 'Name of the new team member. Max length: 128 chars.', true)
->inject('response')
@@ -764,7 +764,7 @@ Http::patch('/v1/teams/:teamId/memberships/:membershipId')
))
->param('teamId', '', new UID(), 'Team ID.')
->param('membershipId', '', new UID(), 'Membership ID.')
->param('roles', [], 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'])
->param('roles', [], new ArrayList(new Key(maxLength: 81), 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']) // For project-specific permissions, roles will be in the format `project-<projectId>-<role>`. Template takes 9 characters, `projectId` and `role` can be upto 36 characters. In total, 81 characters.
->inject('request')
->inject('response')
->inject('user')
+7 -7
View File
@@ -2,6 +2,7 @@
use Appwrite\Event\Build;
use Appwrite\Extend\Exception;
use Appwrite\Filter\BranchDomain as BranchDomainFilter;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
@@ -316,13 +317,12 @@ $createGitDeployments = function (GitHub $github, string $providerInstallationId
// VCS branch preview
if (!empty($providerBranch)) {
$branchPrefix = substr($providerBranch, 0, 16);
if (strlen($providerBranch) > 16) {
$remainingChars = substr($providerBranch, 16);
$branchPrefix .= '-' . substr(hash('sha256', $remainingChars), 0, 7);
}
$resourceProjectHash = substr(hash('sha256', $resource->getId() . $project->getId()), 0, 7);
$domain = "branch-{$branchPrefix}-{$resourceProjectHash}.{$sitesDomain}";
$domain = (new BranchDomainFilter())->apply([
'branch' => $providerBranch,
'resourceId' => $resource->getId(),
'projectId' => $project->getId(),
'sitesDomain' => $sitesDomain,
]);
$ruleId = md5($domain);
try {
$authorization->skip(
+49 -6
View File
@@ -295,12 +295,40 @@ Http::init()
throw new Exception(Exception::USER_UNAUTHORIZED);
}
$scopes = []; // Reset scope if admin
foreach ($adminRoles as $role) {
$scopes = \array_merge($scopes, $roles[$role]['scopes']);
$projectId = $project->getId();
if ($projectId === 'console' && str_starts_with($route->getPath(), '/v1/projects/:projectId')) {
$uri = $request->getURI();
$projectId = explode('/', $uri)[3];
}
$authorization->setDefaultStatus(false); // Cancel security segmentation for admin users.
// Base scopes for admin users to allow listing teams and projects.
// Useful for those who have project-specific roles but don't have team-wide role.
$scopes = ['teams.read', 'projects.read'];
foreach ($adminRoles as $adminRole) {
$isTeamWideRole = !str_starts_with($adminRole, 'project-');
$isProjectSpecificRole = $projectId !== 'console' && str_starts_with($adminRole, 'project-' . $projectId);
if ($isTeamWideRole || $isProjectSpecificRole) {
$role = match (str_starts_with($adminRole, 'project-')) {
true => substr($adminRole, strrpos($adminRole, '-') + 1),
false => $adminRole,
};
$roleScopes = $roles[$role]['scopes'] ?? [];
$scopes = \array_merge($scopes, $roleScopes);
$authorization->addRole($role);
}
}
/**
* For console projects resource, we use platform DB.
* Enabling authorization restricts admin user to the projects they have access to.
*/
if ($project->getId() === 'console' && ($route->getPath() === '/v1/projects' || $route->getPath() === '/v1/projects/:projectId')) {
$authorization->setDefaultStatus(true);
} else {
// Otherwise, disable authorization checks.
$authorization->setDefaultStatus(false);
}
}
$scopes = \array_unique($scopes);
@@ -310,6 +338,21 @@ Http::init()
$authorization->addRole($authRole);
}
/**
* We disable authorization checks above to ensure other endpoints (list teams, members, etc.) will continue working.
* But, for actions on resources (sites, functions, etc.) in a non-console project, we explicitly check
* whether the admin user has necessary permission on the project (sites, functions, etc. don't have permissions associated to them).
*/
if (empty($apiKey) && $project->getId() !== 'console' && $mode === APP_MODE_ADMIN) {
$input = new Input(Database::PERMISSION_READ, $project->getPermissionsByType(Database::PERMISSION_READ));
$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);
@@ -324,10 +367,10 @@ Http::init()
if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_USER_ACCESS)) > $accessedAt) {
$user->setAttribute('accessedAt', DateTime::now());
if (APP_MODE_ADMIN !== $mode) {
if ($project->getId() !== 'console' && APP_MODE_ADMIN !== $mode) {
$dbForProject->updateDocument('users', $user->getId(), $user);
} else {
$dbForPlatform->updateDocument('users', $user->getId(), $user);
$authorization->skip(fn () => $dbForPlatform->updateDocument('users', $user->getId(), $user));
}
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
namespace Appwrite\Filter;
use Utopia\Validator\Text;
class BranchDomain implements Filter
{
/**
* Maximum length for branch prefix in domain name
*/
public const BRANCH_PREFIX_MAX_LENGTH = 16;
/**
* Length of hash suffix when branch name exceeds max length
*/
public const HASH_SUFFIX_LENGTH = 7;
/**
* Pre-process branch name to a valid domain name.
*
* Input should be an array with:
* - 'branch' (string): The branch name
* - 'resourceId' (string): The resource ID (site or function)
* - 'projectId' (string): The project ID
* - 'sitesDomain' (string): The base sites domain
*/
public function apply(mixed $input): mixed
{
$branch = $input['branch'] ?? '';
$resourceId = $input['resourceId'] ?? '';
$projectId = $input['projectId'] ?? '';
$sitesDomain = $input['sitesDomain'] ?? '';
$branchPrefix = $this->generateBranchPrefix($branch);
$resourceProjectHash = substr(hash('sha256', $resourceId . $projectId), 0, self::HASH_SUFFIX_LENGTH);
$domain = \strtolower("branch-{$branchPrefix}-{$resourceProjectHash}.{$sitesDomain}");
return $domain;
}
/**
* Generate a branch prefix for domain name from a branch name.
* Takes up to 16 characters, sanitizes them for domain use,
* and appends a hash suffix if the branch name is longer than 16 characters.
*
* @param string $branch The branch name
* @return string The branch prefix for domain name
*/
private function generateBranchPrefix(string $branch): string
{
$branchPrefix = substr($branch, 0, self::BRANCH_PREFIX_MAX_LENGTH);
$branchPrefix = $this->sanitizeBranchName($branchPrefix);
if (strlen($branch) > self::BRANCH_PREFIX_MAX_LENGTH) {
$remainingChars = substr($branch, self::BRANCH_PREFIX_MAX_LENGTH);
$branchPrefix .= '-' . substr(hash('sha256', $remainingChars), 0, self::HASH_SUFFIX_LENGTH);
}
return $branchPrefix;
}
/**
* Sanitize a branch name for use in a domain name.
* Replaces any characters that are not alphanumeric or hyphens with hyphens,
* and removes leading/trailing hyphens.
*
* @param string $branch The branch name to sanitize
* @return string The sanitized branch name
*/
private function sanitizeBranchName(string $branch): string
{
$allowedChars = array_merge(
Text::NUMBERS,
Text::ALPHABET_UPPER,
Text::ALPHABET_LOWER,
['-']
);
$allowedCharsFlip = array_flip($allowedChars);
$sanitized = '';
for ($i = 0; $i < \strlen($branch); $i++) {
$char = $branch[$i];
if (isset($allowedCharsFlip[$char])) {
$sanitized .= $char;
} else {
// Prevents two -- or more in a row
if (strlen($sanitized) > 0 && $sanitized[strlen($sanitized) - 1] !== '-') {
$sanitized .= '-';
}
}
}
return trim($sanitized, '-');
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace Appwrite\Filter;
interface Filter
{
public function apply(mixed $input): mixed;
}
+9 -24
View File
@@ -4,8 +4,10 @@ namespace Appwrite\Platform\Modules\Compute;
use Appwrite\Event\Build;
use Appwrite\Extend\Exception;
use Appwrite\Filter\BranchDomain as BranchDomainFilter;
use Appwrite\Platform\Action;
use Appwrite\Platform\Modules\Compute\Validator\Specification as SpecificationValidator;
use Appwrite\Platform\Permission as AppwritePermission;
use Utopia\Config\Config;
use Utopia\Database\Database;
use Utopia\Database\Document;
@@ -22,23 +24,7 @@ use Utopia\VCS\Exception\RepositoryNotFound;
class Base extends Action
{
/**
* Permissions for resources in this project.
*
* @param string $teamId
* @param string $projectId
* @return string[]
*/
protected function getPermissions(string $teamId, string $projectId): array
{
return [
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')),
];
}
use AppwritePermission;
/**
* Get default specification based on plan and available specifications.
@@ -326,13 +312,12 @@ class Base extends Action
// VCS branch preview
if (!empty($providerBranch)) {
$branchPrefix = substr($providerBranch, 0, 16);
if (strlen($providerBranch) > 16) {
$remainingChars = substr($providerBranch, 16);
$branchPrefix .= '-' . substr(hash('sha256', $remainingChars), 0, 7);
}
$resourceProjectHash = substr(hash('sha256', $site->getId() . $project->getId()), 0, 7);
$domain = "branch-{$branchPrefix}-{$resourceProjectHash}.{$sitesDomain}";
$domain = (new BranchDomainFilter())->apply([
'branch' => $providerBranch,
'resourceId' => $site->getId(),
'projectId' => $project->getId(),
'sitesDomain' => $sitesDomain,
]);
$ruleId = md5($domain);
try {
$authorization->skip(
@@ -9,6 +9,7 @@ use Appwrite\Event\Realtime;
use Appwrite\Event\Screenshot;
use Appwrite\Event\StatsUsage;
use Appwrite\Event\Webhook;
use Appwrite\Filter\BranchDomain as BranchDomainFilter;
use Appwrite\Utopia\Response\Model\Deployment;
use Appwrite\Vcs\Comment;
use Exception;
@@ -1027,14 +1028,12 @@ class Builds extends Action
// VCS branch
$branchName = $deployment->getAttribute('providerBranch');
if (!empty($branchName)) {
$sitesDomain = $platform['sitesDomain'];
$branchPrefix = substr($branchName, 0, 16);
if (strlen($branchName) > 16) {
$remainingChars = substr($branchName, 16);
$branchPrefix .= '-' . substr(hash('sha256', $remainingChars), 0, 7);
}
$resourceProjectHash = substr(hash('sha256', $resource->getId() . $project->getId()), 0, 7);
$domain = "branch-{$branchPrefix}-{$resourceProjectHash}.{$sitesDomain}";
$domain = (new BranchDomainFilter())->apply([
'branch' => $branchName,
'resourceId' => $resource->getId(),
'projectId' => $project->getId(),
'sitesDomain' => $platform['sitesDomain'],
]);
$ruleId = md5($domain);
try {
@@ -3,20 +3,9 @@
namespace Appwrite\Platform\Modules\Projects\Http\Projects;
use Appwrite\Platform\Action as AppwriteAction;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
use Appwrite\Platform\Permission as AppwritePermission;
class Action extends AppwriteAction
{
protected function getPermissions(string $teamId, string $projectId): array
{
return [
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')),
];
}
use AppwritePermission;
}
@@ -75,7 +75,7 @@ class Create extends Action
$this->validateDomainRestrictions($domain, $platform);
// TODO: (@Meldiron) Remove after 1.7.x migration
$ruleId = System::getEnv('_APP_RULES_FORMAT') === 'md5' ? md5($domain) : ID::unique();
$ruleId = System::getEnv('_APP_RULES_FORMAT') === 'md5' ? md5(\strtolower($domain)) : ID::unique();
$status = RULE_STATUS_CREATED;
$owner = '';
@@ -87,7 +87,7 @@ class Create extends Action
$deployment = $dbForProject->getDocument('deployments', $function->getAttribute('deploymentId', ''));
// TODO: (@Meldiron) Remove after 1.7.x migration
$ruleId = System::getEnv('_APP_RULES_FORMAT') === 'md5' ? md5($domain) : ID::unique();
$ruleId = System::getEnv('_APP_RULES_FORMAT') === 'md5' ? md5(\strtolower($domain)) : ID::unique();
$status = RULE_STATUS_CREATED;
$owner = '';
@@ -92,7 +92,7 @@ class Create extends Action
}
// TODO: (@Meldiron) Remove after 1.7.x migration
$ruleId = System::getEnv('_APP_RULES_FORMAT') === 'md5' ? md5($domain) : ID::unique();
$ruleId = System::getEnv('_APP_RULES_FORMAT') === 'md5' ? md5(\strtolower($domain)) : ID::unique();
$status = RULE_STATUS_CREATED;
$owner = '';
@@ -87,7 +87,7 @@ class Create extends Action
$deployment = $dbForProject->getDocument('deployments', $site->getAttribute('deploymentId', ''));
// TODO: (@Meldiron) Remove after 1.7.x migration
$ruleId = System::getEnv('_APP_RULES_FORMAT') === 'md5' ? md5($domain) : ID::unique();
$ruleId = System::getEnv('_APP_RULES_FORMAT') === 'md5' ? md5(\strtolower($domain)) : ID::unique();
$status = RULE_STATUS_CREATED;
$owner = '';
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace Appwrite\Platform;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission as DbPermission;
use Utopia\Database\Helpers\Role;
trait Permission
{
/**
* Permissions for projects & project resources.
*
* @param string $teamId
* @param string $projectId
* @return array
*/
public function getPermissions(string $teamId, string $projectId): array
{
return [
// Team-wide permissions
DbPermission::read(Role::team(ID::custom($teamId), 'owner')),
DbPermission::read(Role::team(ID::custom($teamId), 'developer')),
DbPermission::update(Role::team(ID::custom($teamId), 'owner')),
DbPermission::update(Role::team(ID::custom($teamId), 'developer')),
DbPermission::delete(Role::team(ID::custom($teamId), 'owner')),
DbPermission::delete(Role::team(ID::custom($teamId), 'developer')),
// Project-wide permissions
DbPermission::read(Role::team(ID::custom($teamId), "project-{$projectId}-owner")),
DbPermission::read(Role::team(ID::custom($teamId), "project-{$projectId}-developer")),
DbPermission::update(Role::team(ID::custom($teamId), "project-{$projectId}-owner")),
DbPermission::update(Role::team(ID::custom($teamId), "project-{$projectId}-developer")),
DbPermission::delete(Role::team(ID::custom($teamId), "project-{$projectId}-owner")),
DbPermission::delete(Role::team(ID::custom($teamId), "project-{$projectId}-developer")),
];
}
}
@@ -39,7 +39,7 @@ class User extends Document
{
$roles = [];
if (!$this->isPrivileged($authorization->getRoles()) && !$this->isApp($authorization->getRoles())) {
if (!$this->isApp($authorization->getRoles())) {
if ($this->getId()) {
$roles[] = Role::user($this->getId())->toString();
$roles[] = Role::users()->toString();
@@ -297,7 +297,7 @@ class TeamsServerTest extends Scope
$this->assertEquals(204, $team['headers']['status-code']);
}
/** @group cl-ignore */
/** @group ciIgnore */
public function testDeleteTeam()
{
$team = $this->testCreateTeam();
+141 -10
View File
@@ -4,27 +4,32 @@ namespace Tests\E2E\Services\Projects;
use Tests\E2E\Client;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Role;
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 +51,130 @@ 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,
];
}
protected function updateMembershipRole(string $teamId, string $membershipId, array $roles): void
{
$response = $this->client->call(Client::METHOD_PATCH, '/teams/' . $teamId . '/memberships/' . $membershipId, array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'roles' => $roles,
]);
$this->assertEquals(200, $response['headers']['status-code']);
}
protected function setupFunction(string $projectId, string $functionId, string $token): void
{
$function = $this->client->call(Client::METHOD_POST, '/functions', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-mode' => 'admin',
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $token,
]), [
'functionId' => $functionId,
'name' => 'Test function',
'execute' => [Role::any()->toString()],
'runtime' => 'node-22',
'entrypoint' => 'index.js',
'events' => [
'users.*.create',
'users.*.delete',
],
'schedule' => '0 0 1 1 *',
'timeout' => 10,
]);
$this->assertEquals(201, $function['headers']['status-code']);
$this->assertNotEmpty($function['body']['$id']);
}
}
@@ -6181,4 +6181,420 @@ class ProjectsConsoleClientTest extends Scope
$this->assertEquals(204, $response['headers']['status-code']);
}
/**
* @group ciIgnore
*/
public function testProjectSpecificPermissionsForListProjects(): void
{
$teamId = ID::unique();
$projectIdA = $this->setupProject([
'projectId' => ID::unique(),
'name' => 'Project Test A',
], $teamId);
$projectIdB = $this->setupProject([
'projectId' => ID::unique(),
'name' => 'Project Test B',
], $teamId, false);
$testUserEmail = 'test-' . ID::unique() . '@localhost.test';
$testUserName = 'Test User';
[ 'membershipId' => $testUserMembershipId ] = $this->setupUserMembership([
'teamId' => $teamId,
'email' => $testUserEmail,
'name' => $testUserName,
'roles' => ["owner"],
]);
$testCases = [
[
'roles' => ["owner"],
'successProjectIds' => [$projectIdA, $projectIdB],
],
[
'roles' => ["developer"],
'successProjectIds' => [$projectIdA, $projectIdB],
],
[
'roles' => ["project-$projectIdA-owner"],
'successProjectIds' => [$projectIdA],
],
[
'roles' => ["project-$projectIdB-owner"],
'successProjectIds' => [$projectIdB],
],
[
'roles' => ["project-$projectIdA-developer"],
'successProjectIds' => [$projectIdA],
],
[
'roles' => ["project-$projectIdB-developer"],
'successProjectIds' => [$projectIdB],
],
[
'roles' => ["developer", "project-$projectIdA-owner"],
'successProjectIds' => [$projectIdA, $projectIdB],
]
];
// Setup session
$session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], [
'email' => $testUserEmail,
'password' => 'password',
]);
$token = $session['cookies']['a_session_' . $this->getProject()['$id']];
foreach ($testCases as $testCase) {
$this->updateMembershipRole($teamId, $testUserMembershipId, $testCase['roles']);
$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['successProjectIds']), $response['body']['projects']);
$returnedProjectIds = \array_column($response['body']['projects'], '$id');
foreach ($testCase['successProjectIds'] as $projectId) {
$this->assertContains($projectId, $returnedProjectIds);
}
}
}
/**
* @group ciIgnore
*/
public function testProjectSpecificPermissionsForUpdateProject(): void
{
$teamId = ID::unique();
$projectIdA = $this->setupProject([
'projectId' => ID::unique(),
'name' => 'Project Test A',
], $teamId);
$projectIdB = $this->setupProject([
'projectId' => ID::unique(),
'name' => 'Project Test B',
], $teamId, false);
$testUserEmail = 'test-' . ID::unique() . '@localhost.test';
$testUserName = 'Test User';
[ 'membershipId' => $testUserMembershipId ] = $this->setupUserMembership([
'teamId' => $teamId,
'email' => $testUserEmail,
'name' => $testUserName,
'roles' => ["owner"],
]);
$testCases = [
[
'roles' => ["owner"],
'successProjectIds' => [$projectIdA, $projectIdB],
'failureProjectIds' => [],
],
[
'roles' => ["developer"],
'successProjectIds' => [$projectIdA, $projectIdB],
'failureProjectIds' => [],
],
[
'roles' => ["project-$projectIdA-owner"],
'successProjectIds' => [$projectIdA],
'failureProjectIds' => [$projectIdB],
],
[
'roles' => ["project-$projectIdB-owner"],
'successProjectIds' => [$projectIdB],
'failureProjectIds' => [$projectIdA],
],
[
'roles' => ["project-$projectIdA-developer"],
'successProjectIds' => [$projectIdA],
'failureProjectIds' => [$projectIdB],
],
[
'roles' => ["project-$projectIdB-developer"],
'successProjectIds' => [$projectIdB],
'failureProjectIds' => [$projectIdA],
],
[
'roles' => ["developer", "project-$projectIdA-owner"],
'successProjectIds' => [$projectIdA, $projectIdB],
'failureProjectIds' => [],
]
];
// Setup session
$session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], [
'email' => $testUserEmail,
'password' => 'password',
]);
$token = $session['cookies']['a_session_' . $this->getProject()['$id']];
foreach ($testCases as $testCase) {
$this->updateMembershipRole($teamId, $testUserMembershipId, $testCase['roles']);
foreach ($testCase['successProjectIds'] 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['failureProjectIds'] 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->assertTrue($response['headers']['status-code'] === 401 || $response['headers']['status-code'] === 404);
}
}
}
/**
* @group ciIgnore
*/
public function testProjectSpecificPermissionsForDeleteProject(): void
{
$teamId = ID::unique();
$projectIdA = $this->setupProject([
'projectId' => ID::unique(),
'name' => 'Project Test A',
], $teamId);
$projectIdB = $this->setupProject([
'projectId' => ID::unique(),
'name' => 'Project Test B',
], $teamId, false);
$projectIdC = $this->setupProject([
'projectId' => ID::unique(),
'name' => 'Project Test C',
], $teamId, false);
$projectIdD = $this->setupProject([
'projectId' => ID::unique(),
'name' => 'Project Test D',
], $teamId, false);
$projectIdE = $this->setupProject([
'projectId' => ID::unique(),
'name' => 'Project Test E',
], $teamId, false);
$testUserEmail = 'test-' . ID::unique() . '@localhost.test';
$testUserName = 'Test User';
[ 'membershipId' => $testUserMembershipId ] = $this->setupUserMembership([
'teamId' => $teamId,
'email' => $testUserEmail,
'name' => $testUserName,
'roles' => ["owner"],
]);
$testCases = [
[
'roles' => ["owner"],
'successProjectIds' => [$projectIdA],
'failureProjectIds' => [],
],
[
'roles' => ["developer"],
'successProjectIds' => [$projectIdB, $projectIdC],
'failureProjectIds' => [],
],
[
'roles' => ["project-$projectIdD-owner"],
'successProjectIds' => [$projectIdD],
'failureProjectIds' => [$projectIdE],
],
[
'roles' => ["project-$projectIdE-owner"],
'successProjectIds' => [$projectIdE],
'failureProjectIds' => [],
],
];
// Setup session
$session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], [
'email' => $testUserEmail,
'password' => 'password',
]);
$token = $session['cookies']['a_session_' . $this->getProject()['$id']];
foreach ($testCases as $testCase) {
$this->updateMembershipRole($teamId, $testUserMembershipId, $testCase['roles']);
foreach ($testCase['successProjectIds'] 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['failureProjectIds'] 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->assertTrue($response['headers']['status-code'] === 401 || $response['headers']['status-code'] === 404);
}
}
}
/**
* @group ciIgnore
* Test project specific permissions for project resources, in this case 'function variables'.
*/
public function testProjectSpecificPermissionsForProjectResources(): void
{
$teamId = ID::unique();
$projectIdA = $this->setupProject([
'projectId' => ID::unique(),
'name' => 'Project Test A',
], $teamId);
$projectIdB = $this->setupProject([
'projectId' => ID::unique(),
'name' => 'Project Test B',
], $teamId, false);
$testUserEmail = 'test-' . ID::unique() . '@localhost.test';
$testUserName = 'Test User';
[ 'membershipId' => $testUserMembershipId ] = $this->setupUserMembership([
'teamId' => $teamId,
'email' => $testUserEmail,
'name' => $testUserName,
'roles' => ["owner"],
]);
$testCases = [
[
'roles' => ["owner"],
'successProjectIds' => [$projectIdA, $projectIdB],
'failureProjectIds' => [],
],
[
'roles' => ["developer"],
'successProjectIds' => [$projectIdA, $projectIdB],
'failureProjectIds' => [],
],
[
'roles' => ["project-$projectIdA-owner"],
'successProjectIds' => [$projectIdA],
'failureProjectIds' => [$projectIdB],
],
[
'roles' => ["project-$projectIdB-owner"],
'successProjectIds' => [$projectIdB],
'failureProjectIds' => [$projectIdA],
],
[
'roles' => ["project-$projectIdA-developer"],
'successProjectIds' => [$projectIdA],
'failureProjectIds' => [$projectIdB],
],
[
'roles' => ["project-$projectIdB-developer"],
'successProjectIds' => [$projectIdB],
'failureProjectIds' => [$projectIdA],
],
[
'roles' => ["developer", "project-$projectIdA-owner"],
'successProjectIds' => [$projectIdA, $projectIdB],
'failureProjectIds' => [],
]
];
// Setup session
$session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], [
'email' => $testUserEmail,
'password' => 'password',
]);
$token = $session['cookies']['a_session_' . $this->getProject()['$id']];
// Setup functions
$functionId = ID::unique();
$this->setupFunction($projectIdA, $functionId, $token);
$this->setupFunction($projectIdB, $functionId, $token);
foreach ($testCases as $testCase) {
$this->updateMembershipRole($teamId, $testUserMembershipId, $testCase['roles']);
foreach ($testCase['successProjectIds'] as $projectId) {
$variableId = ID::unique();
$response = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/variables', [
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-mode' => 'admin',
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $token,
], [
'key' => 'APP_TEST_' . $variableId,
'value' => 'TESTINGVALUE',
'secret' => false
]);
$this->assertEquals(201, $response['headers']['status-code']);
$this->assertEquals('APP_TEST_' . $variableId, $response['body']['key']);
$this->assertEquals('TESTINGVALUE', $response['body']['value']);
}
foreach ($testCase['failureProjectIds'] as $projectId) {
$variableId = ID::unique();
$response = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/variables', [
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-mode' => 'admin',
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $token,
], [
'key' => 'APP_TEST_' . $variableId,
'value' => 'TESTINGVALUE',
'secret' => false
]);
$this->assertTrue($response['headers']['status-code'] === 401 || $response['headers']['status-code'] === 404);
}
}
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace Tests\Unit\Filter;
use Appwrite\Filter\BranchDomain as BranchDomainFilter;
use PHPUnit\Framework\TestCase;
class BranchDomainTest extends TestCase
{
public function testBranchDomain(): void
{
$filter = new BranchDomainFilter();
// Branch name with slash
$domain = $filter->apply([
'branch' => 'feature/test',
'resourceId' => 'site123',
'projectId' => 'proj456',
'sitesDomain' => 'appwrite.network'
]);
$this->assertStringNotContainsString('/', $domain);
$this->assertStringStartsWith('branch-feature-test-', $domain);
$this->assertStringEndsWith('.appwrite.network', $domain);
// Branch domain consistency
$domain2 = $filter->apply([
'branch' => 'feature/test',
'resourceId' => 'site123',
'projectId' => 'proj456',
'sitesDomain' => 'appwrite.network'
]);
$this->assertEquals($domain, $domain);
// Different resources should produce different domains
$domain2 = $filter->apply([
'branch' => 'feature/test',
'resourceId' => 'site789',
'projectId' => 'proj456',
'sitesDomain' => 'appwrite.network'
]);
$this->assertNotEquals($domain, $domain2);
// Different projects should produce different domains
$domain2 = $filter->apply([
'branch' => 'feature/test',
'resourceId' => 'site123',
'projectId' => 'proj789',
'sitesDomain' => 'appwrite.network'
]);
$this->assertNotEquals($domain, $domain2);
// Some real-world branch names
$domain = $filter->apply([
'branch' => 'feature/SER-1234',
'resourceId' => 'site123',
'projectId' => 'proj456',
'sitesDomain' => 'appwrite.network'
]);
$this->assertStringStartsWith('branch-feature-ser-1234-', $domain);
$this->assertStringEndsWith('.appwrite.network', $domain);
$domain = $filter->apply([
'branch' => 'bugfix/fix-login',
'resourceId' => 'site123',
'projectId' => 'proj456',
'sitesDomain' => 'appwrite.network'
]);
$this->assertStringStartsWith('branch-bugfix-fix-login-', $domain);
$this->assertStringEndsWith('.appwrite.network', $domain);
$domain = $filter->apply([
'branch' => 'hotfix/v1.2.3',
'resourceId' => 'site123',
'projectId' => 'proj456',
'sitesDomain' => 'appwrite.network'
]);
$this->assertStringStartsWith('branch-hotfix-v1-2-3-', $domain);
$this->assertStringEndsWith('.appwrite.network', $domain);
$domain = $filter->apply([
'branch' => 'release/2024.01',
'resourceId' => 'site123',
'projectId' => 'proj456',
'sitesDomain' => 'appwrite.network'
]);
$this->assertStringStartsWith('branch-release-2024-01-', $domain);
$this->assertStringEndsWith('.appwrite.network', $domain);
$domain = $filter->apply([
'branch' => 'user/john/experiment',
'resourceId' => 'site123',
'projectId' => 'proj456',
'sitesDomain' => 'appwrite.network'
]);
$this->assertStringStartsWith('branch-user-john-experi-', $domain);
$this->assertStringEndsWith('.appwrite.network', $domain);
$domain = $filter->apply([
'branch' => 'dependabot/npm_and_yarn/lodash-4.17.21',
'resourceId' => 'site123',
'projectId' => 'proj456',
'sitesDomain' => 'appwrite.network'
]);
$this->assertStringStartsWith('branch-dependabot-npm-a-', $domain);
$this->assertStringEndsWith('.appwrite.network', $domain);
}
}
@@ -307,11 +307,11 @@ class UserTest extends TestCase
]);
$roles = $user->getRoles($this->getAuthorization());
$this->assertCount(7, $roles);
$this->assertNotContains(Role::users()->toString(), $roles);
$this->assertNotContains(Role::user(ID::custom('123'))->toString(), $roles);
$this->assertNotContains(Role::users(Roles::DIMENSION_VERIFIED)->toString(), $roles);
$this->assertNotContains(Role::user(ID::custom('123'), Roles::DIMENSION_VERIFIED)->toString(), $roles);
$this->assertCount(11, $roles);
$this->assertContains(Role::users()->toString(), $roles);
$this->assertContains(Role::user(ID::custom('123'))->toString(), $roles);
$this->assertContains(Role::users(Roles::DIMENSION_VERIFIED)->toString(), $roles);
$this->assertContains(Role::user(ID::custom('123'), Roles::DIMENSION_VERIFIED)->toString(), $roles);
$this->assertContains(Role::team(ID::custom('abc'))->toString(), $roles);
$this->assertContains(Role::team(ID::custom('abc'), 'administrator')->toString(), $roles);
$this->assertContains(Role::team(ID::custom('abc'), 'moderator')->toString(), $roles);