diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7bc39392ef..2cc4c700f7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -145,6 +145,3 @@ jobs: - name: Run ${{matrix.service}} Tests run: docker compose exec -T appwrite test /usr/src/code/tests/e2e/Services/${{matrix.service}} --debug - - - name: Run ${{matrix.service}} Shared Tables Tests - run: _APP_DATABASE_SHARED_TABLES=database_db_main docker compose exec -T appwrite test /usr/src/code/tests/e2e/Services/${{matrix.service}} --debug diff --git a/app/cli.php b/app/cli.php index 69d4c1c5a8..da7d23c18d 100644 --- a/app/cli.php +++ b/app/cli.php @@ -109,7 +109,7 @@ CLI::setResource('getProjectDB', function (Group $pools, Database $dbForConsole, if (isset($databases[$dsn->getHost()])) { $database = $databases[$dsn->getHost()]; - if ($dsn->getHost() === System::getEnv('_APP_DATABASE_SHARED_TABLES', '')) { + if ($dsn->getHost() === DATABASE_SHARED_TABLES) { $database ->setSharedTables(true) ->setTenant($project->getInternalId()) @@ -133,7 +133,7 @@ CLI::setResource('getProjectDB', function (Group $pools, Database $dbForConsole, $databases[$dsn->getHost()] = $database; - if ($dsn->getHost() === System::getEnv('_APP_DATABASE_SHARED_TABLES', '')) { + if ($dsn->getHost() === DATABASE_SHARED_TABLES) { $database ->setSharedTables(true) ->setTenant($project->getInternalId()) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 91f19a2358..f872ef311b 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -111,8 +111,35 @@ App::post('/v1/projects') $projectId = ($projectId == 'unique()') ? ID::unique() : $projectId; + $backups['database_db_fra1_v14x_02'] = ['from' => '03:00', 'to' => '05:00']; + $backups['database_db_fra1_v14x_03'] = ['from' => '00:00', 'to' => '02:00']; + $backups['database_db_fra1_v14x_04'] = ['from' => '00:00', 'to' => '02:00']; + $backups['database_db_fra1_v14x_05'] = ['from' => '00:00', 'to' => '02:00']; + $backups['database_db_fra1_v14x_06'] = ['from' => '00:00', 'to' => '02:00']; + $backups['database_db_fra1_v14x_07'] = ['from' => '00:00', 'to' => '02:00']; + $databases = Config::getParam('pools-database', []); + /** + * Remove databases from the list that are currently undergoing an backup + */ + if (count($databases) > 1) { + $now = new \DateTime(); + + foreach ($databases as $index => $database) { + if (empty($backups[$database])) { + continue; + } + $backup = $backups[$database]; + $from = \DateTime::createFromFormat('H:i', $backup['from']); + $to = \DateTime::createFromFormat('H:i', $backup['to']); + if ($now >= $from && $now <= $to) { + unset($databases[$index]); + break; + } + } + } + $databaseOverride = System::getEnv('_APP_DATABASE_OVERRIDE'); $index = \array_search($databaseOverride, $databases); if ($index !== false) { @@ -125,12 +152,37 @@ App::post('/v1/projects') throw new Exception(Exception::PROJECT_RESERVED_PROJECT, "'console' is a reserved project."); } - // TODO: Temporary until all projects are using shared tables. - if ($dsn === System::getEnv('_APP_DATABASE_SHARED_TABLES', '')) { + // TODO: 1 in 5 projects use shared tables. Temporary until all projects are using shared tables. + if ( + ( + !\mt_rand(0, 4) + && System::getEnv('_APP_DATABASE_SHARED_TABLES', 'enabled') === 'enabled' + && System::getEnv('_APP_EDITION', 'self-hosted') !== 'self-hosted' + ) || + ( + $dsn === DATABASE_SHARED_TABLES + ) + ) { $schema = 'appwrite'; $database = 'appwrite'; $namespace = System::getEnv('_APP_DATABASE_SHARED_NAMESPACE', ''); - $dsn = $schema . '://' . System::getEnv('_APP_DATABASE_SHARED_TABLES', '') . '?database=' . $database; + $dsn = $schema . '://' . DATABASE_SHARED_TABLES . '?database=' . $database; + + if (!empty($namespace)) { + $dsn .= '&namespace=' . $namespace; + } + } + + // TODO: Allow overriding in development mode. Temporary until all projects are using shared tables. + if ( + App::isDevelopment() + && System::getEnv('_APP_EDITION', 'self-hosted') !== 'self-hosted' + && $request->getHeader('x-appwrited-share-tables', false) + ) { + $schema = 'appwrite'; + $database = 'appwrite'; + $namespace = System::getEnv('_APP_DATABASE_SHARED_NAMESPACE', ''); + $dsn = $schema . '://' . DATABASE_SHARED_TABLES . '?database=' . $database; if (!empty($namespace)) { $dsn .= '&namespace=' . $namespace; @@ -184,7 +236,7 @@ App::post('/v1/projects') $adapter = $pools->get($dsn->getHost())->pop()->getResource(); $dbForProject = new Database($adapter, $cache); - if ($dsn->getHost() === System::getEnv('_APP_DATABASE_SHARED_TABLES', '')) { + if ($dsn->getHost() === DATABASE_SHARED_TABLES) { $dbForProject ->setSharedTables(true) ->setTenant($project->getInternalId()) diff --git a/app/controllers/general.php b/app/controllers/general.php index 92ddec58f5..15ba10da36 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -584,7 +584,7 @@ App::init() ->addHeader('Server', 'Appwrite') ->addHeader('X-Content-Type-Options', 'nosniff') ->addHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE') - ->addHeader('Access-Control-Allow-Headers', 'Origin, Cookie, Set-Cookie, X-Requested-With, Content-Type, Access-Control-Allow-Origin, Access-Control-Request-Headers, Accept, X-Appwrite-Project, X-Appwrite-Key, X-Appwrite-Locale, X-Appwrite-Mode, X-Appwrite-JWT, X-Appwrite-Response-Format, X-Appwrite-Timeout, X-SDK-Version, X-SDK-Name, X-SDK-Language, X-SDK-Platform, X-SDK-GraphQL, X-Appwrite-ID, X-Appwrite-Timestamp, Content-Range, Range, Cache-Control, Expires, Pragma, X-Forwarded-For, X-Forwarded-User-Agent') + ->addHeader('Access-Control-Allow-Headers', 'Origin, Cookie, Set-Cookie, X-Requested-With, Content-Type, Access-Control-Allow-Origin, Access-Control-Request-Headers, Accept, X-Appwrite-Project, X-Appwrite-Key, X-Appwrite-Locale, X-Appwrite-Mode, X-Appwrite-JWT, X-Appwrite-Response-Format, X-Appwrite-Timeout, X-Appwrite-Shared-Tables, X-SDK-Version, X-SDK-Name, X-SDK-Language, X-SDK-Platform, X-SDK-GraphQL, X-Appwrite-ID, X-Appwrite-Timestamp, Content-Range, Range, Cache-Control, Expires, Pragma, X-Forwarded-For, X-Forwarded-User-Agent') ->addHeader('Access-Control-Expose-Headers', 'X-Appwrite-Session, X-Fallback-Cookies') ->addHeader('Access-Control-Allow-Origin', $refDomain) ->addHeader('Access-Control-Allow-Credentials', 'true'); @@ -635,7 +635,7 @@ App::options() $response ->addHeader('Server', 'Appwrite') ->addHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE') - ->addHeader('Access-Control-Allow-Headers', 'Origin, Cookie, Set-Cookie, X-Requested-With, Content-Type, Access-Control-Allow-Origin, Access-Control-Request-Headers, Accept, X-Appwrite-Project, X-Appwrite-Key, X-Appwrite-Locale, X-Appwrite-Mode, X-Appwrite-JWT, X-Appwrite-Response-Format, X-Appwrite-Timeout, X-SDK-Version, X-SDK-Name, X-SDK-Language, X-SDK-Platform, X-SDK-GraphQL, X-Appwrite-ID, X-Appwrite-Timestamp, Content-Range, Range, Cache-Control, Expires, Pragma, X-Appwrite-Session, X-Fallback-Cookies, X-Forwarded-For, X-Forwarded-User-Agent') + ->addHeader('Access-Control-Allow-Headers', 'Origin, Cookie, Set-Cookie, X-Requested-With, Content-Type, Access-Control-Allow-Origin, Access-Control-Request-Headers, Accept, X-Appwrite-Project, X-Appwrite-Key, X-Appwrite-Locale, X-Appwrite-Mode, X-Appwrite-JWT, X-Appwrite-Response-Format, X-Appwrite-Timeout, X-Appwrite-Shared-Tables, X-SDK-Version, X-SDK-Name, X-SDK-Language, X-SDK-Platform, X-SDK-GraphQL, X-Appwrite-ID, X-Appwrite-Timestamp, Content-Range, Range, Cache-Control, Expires, Pragma, X-Appwrite-Session, X-Fallback-Cookies, X-Forwarded-For, X-Forwarded-User-Agent') ->addHeader('Access-Control-Expose-Headers', 'X-Appwrite-Session, X-Fallback-Cookies') ->addHeader('Access-Control-Allow-Origin', $origin) ->addHeader('Access-Control-Allow-Credentials', 'true') diff --git a/app/init.php b/app/init.php index a86156c750..6df2c02908 100644 --- a/app/init.php +++ b/app/init.php @@ -143,6 +143,9 @@ const APP_SOCIAL_STACKSHARE = 'https://stackshare.io/appwrite'; const APP_SOCIAL_YOUTUBE = 'https://www.youtube.com/c/appwrite?sub_confirmation=1'; const APP_HOSTNAME_INTERNAL = 'appwrite'; +// Databases +const DATABASE_SHARED_TABLES = 'database_db_fra1_self_hosted_16_0'; + // Database Reconnect const DATABASE_RECONNECT_SLEEP = 2; const DATABASE_RECONNECT_MAX_ATTEMPTS = 10; @@ -1335,7 +1338,7 @@ App::setResource('dbForProject', function (Group $pools, Database $dbForConsole, $dsn = new DSN('mysql://' . $project->getAttribute('database')); } - if ($dsn->getHost() === System::getEnv('_APP_DATABASE_SHARED_TABLES', '')) { + if ($dsn->getHost() === DATABASE_SHARED_TABLES) { $database ->setSharedTables(true) ->setTenant($project->getInternalId()) @@ -1388,7 +1391,7 @@ App::setResource('getProjectDB', function (Group $pools, Database $dbForConsole, ->setMetadata('project', $project->getId()) ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS); - if ($dsn->getHost() === System::getEnv('_APP_DATABASE_SHARED_TABLES', '')) { + if ($dsn->getHost() === DATABASE_SHARED_TABLES) { $database ->setSharedTables(true) ->setTenant($project->getInternalId()) diff --git a/app/realtime.php b/app/realtime.php index cde4327417..2904b1db9c 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -92,7 +92,7 @@ if (!function_exists("getProjectDB")) { $database = new Database($adapter, getCache()); - if ($dsn->getHost() === System::getEnv('_APP_DATABASE_SHARED_TABLES', '')) { + if ($dsn->getHost() === DATABASE_SHARED_TABLES) { $database ->setSharedTables(true) ->setTenant($project->getInternalId()) diff --git a/app/worker.php b/app/worker.php index 314fc65b8e..60358ad6b2 100644 --- a/app/worker.php +++ b/app/worker.php @@ -93,7 +93,7 @@ Server::setResource('dbForProject', function (Cache $cache, Registry $register, $dsn = new DSN('mysql://' . $project->getAttribute('database')); } - if ($dsn->getHost() === System::getEnv('_APP_DATABASE_SHARED_TABLES', '')) { + if ($dsn->getHost() === DATABASE_SHARED_TABLES) { $database ->setSharedTables(true) ->setTenant($project->getInternalId()) @@ -126,7 +126,7 @@ Server::setResource('getProjectDB', function (Group $pools, Database $dbForConso if (isset($databases[$dsn->getHost()])) { $database = $databases[$dsn->getHost()]; - if ($dsn->getHost() === System::getEnv('_APP_DATABASE_SHARED_TABLES', '')) { + if ($dsn->getHost() === DATABASE_SHARED_TABLES) { $database ->setSharedTables(true) ->setTenant($project->getInternalId()) @@ -150,7 +150,7 @@ Server::setResource('getProjectDB', function (Group $pools, Database $dbForConso $databases[$dsn->getHost()] = $database; - if ($dsn->getHost() === System::getEnv('_APP_DATABASE_SHARED_TABLES', '')) { + if ($dsn->getHost() === DATABASE_SHARED_TABLES) { $database ->setSharedTables(true) ->setTenant($project->getInternalId()) diff --git a/docker-compose.yml b/docker-compose.yml index b86af1c12b..b1e30a1e07 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -189,7 +189,6 @@ services: - _APP_CONSOLE_COUNTRIES_DENYLIST - _APP_EXPERIMENT_LOGGING_PROVIDER - _APP_EXPERIMENT_LOGGING_CONFIG - - _APP_DATABASE_SHARED_TABLES appwrite-realtime: entrypoint: realtime @@ -239,7 +238,6 @@ services: - _APP_USAGE_STATS - _APP_LOGGING_PROVIDER - _APP_LOGGING_CONFIG - - _APP_DATABASE_SHARED_TABLES appwrite-worker-audits: entrypoint: worker-audits @@ -269,7 +267,6 @@ services: - _APP_DB_PASS - _APP_LOGGING_PROVIDER - _APP_LOGGING_CONFIG - - _APP_DATABASE_SHARED_TABLES appwrite-worker-webhooks: entrypoint: worker-webhooks @@ -302,7 +299,6 @@ services: - _APP_LOGGING_PROVIDER - _APP_LOGGING_CONFIG - _APP_WEBHOOK_MAX_FAILED_ATTEMPTS - - _APP_DATABASE_SHARED_TABLES appwrite-worker-deletes: entrypoint: worker-deletes @@ -360,7 +356,6 @@ services: - _APP_LOGGING_CONFIG - _APP_EXECUTOR_SECRET - _APP_EXECUTOR_HOST - - _APP_DATABASE_SHARED_TABLES appwrite-worker-databases: entrypoint: worker-databases @@ -392,7 +387,6 @@ services: - _APP_LOGGING_CONFIG - _APP_WORKERS_NUM - _APP_QUEUE_NAME - - _APP_DATABASE_SHARED_TABLES appwrite-worker-builds: entrypoint: worker-builds @@ -458,7 +452,6 @@ services: - _APP_STORAGE_WASABI_SECRET - _APP_STORAGE_WASABI_REGION - _APP_STORAGE_WASABI_BUCKET - - _APP_DATABASE_SHARED_TABLES appwrite-worker-certificates: entrypoint: worker-certificates @@ -494,7 +487,6 @@ services: - _APP_DB_PASS - _APP_LOGGING_PROVIDER - _APP_LOGGING_CONFIG - - _APP_DATABASE_SHARED_TABLES appwrite-worker-functions: entrypoint: worker-functions @@ -534,7 +526,6 @@ services: - _APP_DOCKER_HUB_PASSWORD - _APP_LOGGING_CONFIG - _APP_LOGGING_PROVIDER - - _APP_DATABASE_SHARED_TABLES appwrite-worker-mails: entrypoint: worker-mails @@ -569,7 +560,6 @@ services: - _APP_LOGGING_CONFIG - _APP_DOMAIN - _APP_OPTIONS_FORCE_HTTPS - - _APP_DATABASE_SHARED_TABLES appwrite-worker-messaging: entrypoint: worker-messaging @@ -624,7 +614,6 @@ services: - _APP_STORAGE_WASABI_SECRET - _APP_STORAGE_WASABI_REGION - _APP_STORAGE_WASABI_BUCKET - - _APP_DATABASE_SHARED_TABLES appwrite-worker-migrations: entrypoint: worker-migrations @@ -660,7 +649,6 @@ services: - _APP_LOGGING_CONFIG - _APP_MIGRATIONS_FIREBASE_CLIENT_ID - _APP_MIGRATIONS_FIREBASE_CLIENT_SECRET - - _APP_DATABASE_SHARED_TABLES appwrite-task-maintenance: entrypoint: maintenance @@ -698,7 +686,6 @@ services: - _APP_MAINTENANCE_RETENTION_USAGE_HOURLY - _APP_MAINTENANCE_RETENTION_SCHEDULES - _APP_MAINTENANCE_DELAY - - _APP_DATABASE_SHARED_TABLES appwrite-worker-usage: entrypoint: worker-usage @@ -730,7 +717,6 @@ services: - _APP_LOGGING_PROVIDER - _APP_LOGGING_CONFIG - _APP_USAGE_AGGREGATION_INTERVAL - - _APP_DATABASE_SHARED_TABLES appwrite-worker-usage-dump: entrypoint: worker-usage-dump @@ -762,7 +748,6 @@ services: - _APP_LOGGING_PROVIDER - _APP_LOGGING_CONFIG - _APP_USAGE_AGGREGATION_INTERVAL - - _APP_DATABASE_SHARED_TABLES appwrite-task-scheduler-functions: entrypoint: schedule-functions @@ -790,7 +775,6 @@ services: - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DATABASE_SHARED_TABLES appwrite-task-scheduler-messages: entrypoint: schedule-messages @@ -818,7 +802,6 @@ services: - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS - - _APP_DATABASE_SHARED_TABLES appwrite-assistant: container_name: appwrite-assistant @@ -917,7 +900,20 @@ services: - MYSQL_USER=${_APP_DB_USER} - MYSQL_PASSWORD=${_APP_DB_PASS} - MARIADB_AUTO_UPGRADE=1 - command: "mysqld --innodb-flush-method=fsync" + command: "mysqld --innodb-flush-method=fsync" # add ' --query_cache_size=0' for DB tests + # command: mv /var/lib/mysql/ib_logfile0 /var/lib/mysql/ib_logfile0.bu && mv /var/lib/mysql/ib_logfile1 /var/lib/mysql/ib_logfile1.bu + + # smtp: + # image: appwrite/smtp:1.2.0 + # container_name: appwrite-smtp + # restart: unless-stopped + # networks: + # - appwrite + # environment: + # - LOCAL_DOMAINS=@ + # - RELAY_FROM_HOSTS=192.168.0.0/16 ; *.yourdomain.com + # - SMARTHOST_HOST=smtp + # - SMARTHOST_PORT=587 redis: image: redis:7.2.4-alpine @@ -935,6 +931,14 @@ services: volumes: - appwrite-redis:/data:rw + # clamav: + # image: appwrite/clamav:1.2.0 + # container_name: appwrite-clamav + # networks: + # - appwrite + # volumes: + # - appwrite-uploads:/storage/uploads + # Dev Tools Start ------------------------------------------------------------------------------------------ # # The Appwrite Team uses the following tools to help debug, monitor and diagnose the Appwrite stack diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index d54f3f5079..49b41da495 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -498,14 +498,14 @@ class Deletes extends Action $collections = $dbForProject->listCollections($limit); foreach ($collections as $collection) { - if ($dsn->getHost() !== System::getEnv('_APP_DATABASE_SHARED_TABLES', '') || !\in_array($collection->getId(), $projectCollectionIds)) { + if ($dsn->getHost() !== DATABASE_SHARED_TABLES || !\in_array($collection->getId(), $projectCollectionIds)) { $dbForProject->deleteCollection($collection->getId()); } else { $this->deleteByGroup($collection->getId(), [], database: $dbForProject); } } - if ($dsn->getHost() === System::getEnv('_APP_DATABASE_SHARED_TABLES', '')) { + if ($dsn->getHost() === DATABASE_SHARED_TABLES) { $collectionsIds = \array_map(fn ($collection) => $collection->getId(), $collections); if (empty(\array_diff($collectionsIds, $projectCollectionIds))) { @@ -554,7 +554,7 @@ class Deletes extends Action ], $dbForConsole); // Delete metadata table - if ($dsn->getHost() !== System::getEnv('_APP_DATABASE_SHARED_TABLES', '')) { + if ($dsn->getHost() !== DATABASE_SHARED_TABLES) { $dbForProject->deleteCollection('_metadata'); } else { $this->deleteByGroup('_metadata', [], $dbForProject); diff --git a/tests/e2e/General/HTTPTest.php b/tests/e2e/General/HTTPTest.php index 92bc52561c..0bb5ca4650 100644 --- a/tests/e2e/General/HTTPTest.php +++ b/tests/e2e/General/HTTPTest.php @@ -31,7 +31,7 @@ class HTTPTest extends Scope $this->assertEquals(204, $response['headers']['status-code']); $this->assertEquals('Appwrite', $response['headers']['server']); $this->assertEquals('GET, POST, PUT, PATCH, DELETE', $response['headers']['access-control-allow-methods']); - $this->assertEquals('Origin, Cookie, Set-Cookie, X-Requested-With, Content-Type, Access-Control-Allow-Origin, Access-Control-Request-Headers, Accept, X-Appwrite-Project, X-Appwrite-Key, X-Appwrite-Locale, X-Appwrite-Mode, X-Appwrite-JWT, X-Appwrite-Response-Format, X-Appwrite-Timeout, X-SDK-Version, X-SDK-Name, X-SDK-Language, X-SDK-Platform, X-SDK-GraphQL, X-Appwrite-ID, X-Appwrite-Timestamp, Content-Range, Range, Cache-Control, Expires, Pragma, X-Appwrite-Session, X-Fallback-Cookies, X-Forwarded-For, X-Forwarded-User-Agent', $response['headers']['access-control-allow-headers']); + $this->assertEquals('Origin, Cookie, Set-Cookie, X-Requested-With, Content-Type, Access-Control-Allow-Origin, Access-Control-Request-Headers, Accept, X-Appwrite-Project, X-Appwrite-Key, X-Appwrite-Locale, X-Appwrite-Mode, X-Appwrite-JWT, X-Appwrite-Response-Format, X-Appwrite-Timeout, X-Appwrite-Shared-Tables, X-SDK-Version, X-SDK-Name, X-SDK-Language, X-SDK-Platform, X-SDK-GraphQL, X-Appwrite-ID, X-Appwrite-Timestamp, Content-Range, Range, Cache-Control, Expires, Pragma, X-Appwrite-Session, X-Fallback-Cookies, X-Forwarded-For, X-Forwarded-User-Agent', $response['headers']['access-control-allow-headers']); $this->assertEquals('X-Appwrite-Session, X-Fallback-Cookies', $response['headers']['access-control-expose-headers']); $this->assertEquals('http://localhost', $response['headers']['access-control-allow-origin']); $this->assertEquals('true', $response['headers']['access-control-allow-credentials']); diff --git a/tests/e2e/Services/Functions/FunctionsCustomClientTest.php b/tests/e2e/Services/Functions/FunctionsCustomClientTest.php index 966f948ce6..119c1a2223 100644 --- a/tests/e2e/Services/Functions/FunctionsCustomClientTest.php +++ b/tests/e2e/Services/Functions/FunctionsCustomClientTest.php @@ -2,7 +2,6 @@ namespace Tests\E2E\Services\Functions; -use Appwrite\Tests\Retry; use CURLFile; use Tests\E2E\Client; use Tests\E2E\Scopes\ProjectCustom; @@ -43,7 +42,6 @@ class FunctionsCustomClientTest extends Scope return []; } - #[Retry(count: 2)] public function testCreateExecution(): array { /** diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 2af0c8f1cc..8cdd325501 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -9,6 +9,7 @@ use Tests\E2E\General\UsageTest; use Tests\E2E\Scopes\ProjectConsole; use Tests\E2E\Scopes\Scope; use Tests\E2E\Scopes\SideClient; +use Utopia\Database\Database; use Utopia\Database\DateTime; use Utopia\Database\Document; use Utopia\Database\Helpers\ID; @@ -3493,4 +3494,504 @@ class ProjectsConsoleClientTest extends Scope return $data; } + + public function testTenantIsolation(): void + { + // Create a team and a project + $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' => 'Amazing Team', + ]); + + $teamId = $team['body']['$id']; + + // Project-level isolation + $project1 = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-shared-tables' => false + ], $this->getHeaders()), [ + 'projectId' => ID::unique(), + 'name' => 'Amazing Project', + 'teamId' => $teamId, + 'region' => 'default' + ]); + + // Application level isolation (shared tables) + $project2 = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-shared-tables' => true + ], $this->getHeaders()), [ + 'projectId' => ID::unique(), + 'name' => 'Amazing Project', + 'teamId' => $teamId, + 'region' => 'default' + ]); + + // Project-level isolation + $project3 = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-shared-tables' => false + ], $this->getHeaders()), [ + 'projectId' => ID::unique(), + 'name' => 'Amazing Project', + 'teamId' => $teamId, + 'region' => 'default' + ]); + + // Application level isolation (shared tables) + $project4 = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-shared-tables' => true + ], $this->getHeaders()), [ + 'projectId' => ID::unique(), + 'name' => 'Amazing Project', + 'teamId' => $teamId, + 'region' => 'default' + ]); + + // Create and API key in each project + $key1 = $this->client->call(Client::METHOD_POST, '/projects/' . $project1['body']['$id'] . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'name' => 'Key Test', + 'scopes' => ['databases.read', 'databases.write', 'collections.read', 'collections.write', 'attributes.read', 'attributes.write', 'indexes.read', 'indexes.write', 'documents.read', 'documents.write', 'users.read', 'users.write'], + ]); + + $key2 = $this->client->call(Client::METHOD_POST, '/projects/' . $project2['body']['$id'] . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'name' => 'Key Test', + 'scopes' => ['databases.read', 'databases.write', 'collections.read', 'collections.write', 'attributes.read', 'attributes.write', 'indexes.read', 'indexes.write', 'documents.read', 'documents.write', 'users.read', 'users.write'], + ]); + + $key3 = $this->client->call(Client::METHOD_POST, '/projects/' . $project3['body']['$id'] . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'name' => 'Key Test', + 'scopes' => ['databases.read', 'databases.write', 'collections.read', 'collections.write', 'attributes.read', 'attributes.write', 'indexes.read', 'indexes.write', 'documents.read', 'documents.write', 'users.read', 'users.write'], + ]); + + $key4 = $this->client->call(Client::METHOD_POST, '/projects/' . $project4['body']['$id'] . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'name' => 'Key Test', + 'scopes' => ['databases.read', 'databases.write', 'collections.read', 'collections.write', 'attributes.read', 'attributes.write', 'indexes.read', 'indexes.write', 'documents.read', 'documents.write', 'users.read', 'users.write'], + ]); + + // Create a database in each project + $database1 = $this->client->call(Client::METHOD_POST, '/databases', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project1['body']['$id'], + 'x-appwrite-key' => $key1['body']['secret'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'Amazing Database', + ]); + + $database2 = $this->client->call(Client::METHOD_POST, '/databases', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project2['body']['$id'], + 'x-appwrite-key' => $key2['body']['secret'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'Amazing Database', + ]); + + $database3 = $this->client->call(Client::METHOD_POST, '/databases', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project3['body']['$id'], + 'x-appwrite-key' => $key3['body']['secret'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'Amazing Database', + ]); + + $database4 = $this->client->call(Client::METHOD_POST, '/databases', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project4['body']['$id'], + 'x-appwrite-key' => $key4['body']['secret'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'Amazing Database', + ]); + + // Create a collection in each project + $collection1 = $this->client->call(Client::METHOD_POST, '/databases/' . $database1['body']['$id'] . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project1['body']['$id'], + 'x-appwrite-key' => $key1['body']['secret'] + ], [ + 'databaseId' => $database1['body']['$id'], + 'collectionId' => ID::unique(), + 'name' => 'Amazing Collection', + ]); + + $collection2 = $this->client->call(Client::METHOD_POST, '/databases/' . $database2['body']['$id'] . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project2['body']['$id'], + 'x-appwrite-key' => $key2['body']['secret'] + ], [ + 'databaseId' => $database2['body']['$id'], + 'collectionId' => ID::unique(), + 'name' => 'Amazing Collection', + ]); + + $collection3 = $this->client->call(Client::METHOD_POST, '/databases/' . $database3['body']['$id'] . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project3['body']['$id'], + 'x-appwrite-key' => $key3['body']['secret'] + ], [ + 'databaseId' => $database3['body']['$id'], + 'collectionId' => ID::unique(), + 'name' => 'Amazing Collection', + ]); + + $collection4 = $this->client->call(Client::METHOD_POST, '/databases/' . $database4['body']['$id'] . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project4['body']['$id'], + 'x-appwrite-key' => $key4['body']['secret'] + ], [ + 'databaseId' => $database4['body']['$id'], + 'collectionId' => ID::unique(), + 'name' => 'Amazing Collection', + ]); + + // Create an attribute in each project + $attribute1 = $this->client->call(Client::METHOD_POST, '/databases/' . $database1['body']['$id'] . '/collections/' . $collection1['body']['$id'] . '/attributes/string', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project1['body']['$id'], + 'x-appwrite-key' => $key1['body']['secret'] + ], [ + 'databaseId' => $database1['body']['$id'], + 'collectionId' => $collection1['body']['$id'], + 'key' => ID::unique(), + 'size' => 255, + 'required' => true + ]); + + $attribute2 = $this->client->call(Client::METHOD_POST, '/databases/' . $database2['body']['$id'] . '/collections/' . $collection2['body']['$id'] . '/attributes/string', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project2['body']['$id'], + 'x-appwrite-key' => $key2['body']['secret'] + ], [ + 'databaseId' => $database2['body']['$id'], + 'collectionId' => $collection2['body']['$id'], + 'key' => ID::unique(), + 'size' => 255, + 'required' => true + ]); + + $attribute3 = $this->client->call(Client::METHOD_POST, '/databases/' . $database3['body']['$id'] . '/collections/' . $collection3['body']['$id'] . '/attributes/string', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project3['body']['$id'], + 'x-appwrite-key' => $key3['body']['secret'] + ], [ + 'databaseId' => $database3['body']['$id'], + 'collectionId' => $collection3['body']['$id'], + 'key' => ID::unique(), + 'size' => 255, + 'required' => true + ]); + + $attribute4 = $this->client->call(Client::METHOD_POST, '/databases/' . $database4['body']['$id'] . '/collections/' . $collection4['body']['$id'] . '/attributes/string', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project4['body']['$id'], + 'x-appwrite-key' => $key4['body']['secret'] + ], [ + 'databaseId' => $database4['body']['$id'], + 'collectionId' => $collection4['body']['$id'], + 'key' => ID::unique(), + 'size' => 255, + 'required' => true + ]); + + // Wait for attributes + \sleep(2); + + // Create an index in each project + $index1 = $this->client->call(Client::METHOD_POST, '/databases/' . $database1['body']['$id'] . '/collections/' . $collection1['body']['$id'] . '/indexes', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project1['body']['$id'], + 'x-appwrite-key' => $key1['body']['secret'] + ], [ + 'databaseId' => $database1['body']['$id'], + 'collectionId' => $collection1['body']['$id'], + 'key' => ID::unique(), + 'type' => Database::INDEX_KEY, + 'attributes' => [$attribute1['body']['key']], + ]); + + $index2 = $this->client->call(Client::METHOD_POST, '/databases/' . $database2['body']['$id'] . '/collections/' . $collection2['body']['$id'] . '/indexes', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project2['body']['$id'], + 'x-appwrite-key' => $key2['body']['secret'] + ], [ + 'databaseId' => $database2['body']['$id'], + 'collectionId' => $collection2['body']['$id'], + 'key' => ID::unique(), + 'type' => Database::INDEX_KEY, + 'attributes' => [$attribute2['body']['key']], + ]); + + $index3 = $this->client->call(Client::METHOD_POST, '/databases/' . $database3['body']['$id'] . '/collections/' . $collection3['body']['$id'] . '/indexes', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project3['body']['$id'], + 'x-appwrite-key' => $key3['body']['secret'] + ], [ + 'databaseId' => $database3['body']['$id'], + 'collectionId' => $collection3['body']['$id'], + 'key' => ID::unique(), + 'type' => Database::INDEX_KEY, + 'attributes' => [$attribute3['body']['key']], + ]); + + $index4 = $this->client->call(Client::METHOD_POST, '/databases/' . $database4['body']['$id'] . '/collections/' . $collection4['body']['$id'] . '/indexes', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project4['body']['$id'], + 'x-appwrite-key' => $key4['body']['secret'] + ], [ + 'databaseId' => $database4['body']['$id'], + 'collectionId' => $collection4['body']['$id'], + 'key' => ID::unique(), + 'type' => Database::INDEX_KEY, + 'attributes' => [$attribute4['body']['key']], + ]); + + // Wait for indexes + \sleep(2); + + // Assert that each project has only 1 database, 1 collection, 1 attribute and 1 index + $databasesProject1 = $this->client->call(Client::METHOD_GET, '/databases', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project1['body']['$id'], + 'x-appwrite-key' => $key1['body']['secret'] + ]); + + $this->assertEquals(1, $databasesProject1['body']['total']); + $this->assertEquals(1, \count($databasesProject1['body']['databases'])); + + $databasesProject2 = $this->client->call(Client::METHOD_GET, '/databases', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project2['body']['$id'], + 'x-appwrite-key' => $key2['body']['secret'] + ]); + + $this->assertEquals(1, $databasesProject2['body']['total']); + $this->assertEquals(1, \count($databasesProject2['body']['databases'])); + + $databasesProject3 = $this->client->call(Client::METHOD_GET, '/databases', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project3['body']['$id'], + 'x-appwrite-key' => $key3['body']['secret'] + ]); + + $this->assertEquals(1, $databasesProject3['body']['total']); + $this->assertEquals(1, \count($databasesProject3['body']['databases'])); + + $databasesProject4 = $this->client->call(Client::METHOD_GET, '/databases', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project4['body']['$id'], + 'x-appwrite-key' => $key4['body']['secret'] + ]); + + $this->assertEquals(1, $databasesProject4['body']['total']); + $this->assertEquals(1, \count($databasesProject4['body']['databases'])); + + $collectionsProject1 = $this->client->call(Client::METHOD_GET, '/databases/' . $database1['body']['$id'] . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project1['body']['$id'], + 'x-appwrite-key' => $key1['body']['secret'] + ]); + + $this->assertEquals(1, $collectionsProject1['body']['total']); + $this->assertEquals(1, \count($collectionsProject1['body']['collections'])); + + $collectionsProject2 = $this->client->call(Client::METHOD_GET, '/databases/' . $database2['body']['$id'] . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project2['body']['$id'], + 'x-appwrite-key' => $key2['body']['secret'] + ]); + + $this->assertEquals(1, $collectionsProject2['body']['total']); + $this->assertEquals(1, \count($collectionsProject2['body']['collections'])); + + $collectionsProject3 = $this->client->call(Client::METHOD_GET, '/databases/' . $database3['body']['$id'] . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project3['body']['$id'], + 'x-appwrite-key' => $key3['body']['secret'] + ]); + + $this->assertEquals(1, $collectionsProject3['body']['total']); + $this->assertEquals(1, \count($collectionsProject3['body']['collections'])); + + $collectionsProject4 = $this->client->call(Client::METHOD_GET, '/databases/' . $database4['body']['$id'] . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project4['body']['$id'], + 'x-appwrite-key' => $key4['body']['secret'] + ]); + + $this->assertEquals(1, $collectionsProject4['body']['total']); + $this->assertEquals(1, \count($collectionsProject4['body']['collections'])); + + $attributesProject1 = $this->client->call(Client::METHOD_GET, '/databases/' . $database1['body']['$id'] . '/collections/' . $collection1['body']['$id'] . '/attributes', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project1['body']['$id'], + 'x-appwrite-key' => $key1['body']['secret'] + ]); + + $this->assertEquals(1, $attributesProject1['body']['total']); + $this->assertEquals(1, \count($attributesProject1['body']['attributes'])); + $this->assertEquals('available', $attributesProject1['body']['attributes'][0]['status']); + + $attributesProject2 = $this->client->call(Client::METHOD_GET, '/databases/' . $database2['body']['$id'] . '/collections/' . $collection2['body']['$id'] . '/attributes', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project2['body']['$id'], + 'x-appwrite-key' => $key2['body']['secret'] + ]); + + $this->assertEquals(1, $attributesProject2['body']['total']); + $this->assertEquals(1, \count($attributesProject2['body']['attributes'])); + $this->assertEquals('available', $attributesProject2['body']['attributes'][0]['status']); + + $attributesProject3 = $this->client->call(Client::METHOD_GET, '/databases/' . $database3['body']['$id'] . '/collections/' . $collection3['body']['$id'] . '/attributes', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project3['body']['$id'], + 'x-appwrite-key' => $key3['body']['secret'] + ]); + + $this->assertEquals(1, $attributesProject3['body']['total']); + $this->assertEquals(1, \count($attributesProject3['body']['attributes'])); + $this->assertEquals('available', $attributesProject3['body']['attributes'][0]['status']); + + $attributesProject4 = $this->client->call(Client::METHOD_GET, '/databases/' . $database4['body']['$id'] . '/collections/' . $collection4['body']['$id'] . '/attributes', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project4['body']['$id'], + 'x-appwrite-key' => $key4['body']['secret'] + ]); + + $this->assertEquals(1, $attributesProject4['body']['total']); + $this->assertEquals(1, \count($attributesProject4['body']['attributes'])); + $this->assertEquals('available', $attributesProject4['body']['attributes'][0]['status']); + + $indexesProject1 = $this->client->call(Client::METHOD_GET, '/databases/' . $database1['body']['$id'] . '/collections/' . $collection1['body']['$id'] . '/indexes', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project1['body']['$id'], + 'x-appwrite-key' => $key1['body']['secret'] + ]); + + $this->assertEquals(1, $indexesProject1['body']['total']); + $this->assertEquals(1, \count($indexesProject1['body']['indexes'])); + + $indexesProject2 = $this->client->call(Client::METHOD_GET, '/databases/' . $database2['body']['$id'] . '/collections/' . $collection2['body']['$id'] . '/indexes', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project2['body']['$id'], + 'x-appwrite-key' => $key2['body']['secret'] + ]); + + $this->assertEquals(1, $indexesProject2['body']['total']); + $this->assertEquals(1, \count($indexesProject2['body']['indexes'])); + + $indexesProject3 = $this->client->call(Client::METHOD_GET, '/databases/' . $database3['body']['$id'] . '/collections/' . $collection3['body']['$id'] . '/indexes', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project3['body']['$id'], + 'x-appwrite-key' => $key3['body']['secret'] + ]); + + $this->assertEquals(1, $indexesProject3['body']['total']); + $this->assertEquals(1, \count($indexesProject3['body']['indexes'])); + + $indexesProject4 = $this->client->call(Client::METHOD_GET, '/databases/' . $database4['body']['$id'] . '/collections/' . $collection4['body']['$id'] . '/indexes', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project4['body']['$id'], + 'x-appwrite-key' => $key4['body']['secret'] + ]); + + $this->assertEquals(1, $indexesProject4['body']['total']); + $this->assertEquals(1, \count($indexesProject4['body']['indexes'])); + + // Attempt to read cross-type resources + $collectionProject2WithProject1Key = $this->client->call(Client::METHOD_GET, '/databases/' . $database2['body']['$id'] . '/collections/' . $collection2['body']['$id'], [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project1['body']['$id'], + 'x-appwrite-key' => $key1['body']['secret'] + ]); + + $this->assertEquals(404, $collectionProject2WithProject1Key['headers']['status-code']); + + $collectionProject1WithProject2Key = $this->client->call(Client::METHOD_GET, '/databases/' . $database1['body']['$id'] . '/collections/' . $collection1['body']['$id'], [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project2['body']['$id'], + 'x-appwrite-key' => $key2['body']['secret'] + ]); + + $this->assertEquals(404, $collectionProject1WithProject2Key['headers']['status-code']); + + // Attempt to read cross-tenant resources + $collectionProject3WithProject1Key = $this->client->call(Client::METHOD_GET, '/databases/' . $database3['body']['$id'] . '/collections/' . $collection3['body']['$id'], [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project1['body']['$id'], + 'x-appwrite-key' => $key1['body']['secret'] + ]); + + $this->assertEquals(404, $collectionProject3WithProject1Key['headers']['status-code']); + + $collectionProject1WithProject3Key = $this->client->call(Client::METHOD_GET, '/databases/' . $database1['body']['$id'] . '/collections/' . $collection1['body']['$id'], [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project3['body']['$id'], + 'x-appwrite-key' => $key3['body']['secret'] + ]); + + $this->assertEquals(404, $collectionProject1WithProject3Key['headers']['status-code']); + + // Assert that shared project resources can have the same ID as they're unique on tenant + ID not just ID + $collection5 = $this->client->call(Client::METHOD_POST, '/databases/' . $database2['body']['$id'] . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project2['body']['$id'], + 'x-appwrite-key' => $key2['body']['secret'] + ], [ + 'databaseId' => $database2['body']['$id'], + 'collectionId' => $collection4['body']['$id'], + 'name' => 'Amazing Collection', + ]); + + $this->assertEquals(201, $collection5['headers']['status-code']); + + // Assert that users across projects on shared tables can have the same email as they're unique on tenant + email not just email + $user1 = $this->client->call(Client::METHOD_POST, '/users', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project2['body']['$id'], + 'x-appwrite-key' => $key2['body']['secret'] + ], [ + 'userId' => 'user', + 'email' => 'test@appwrite.io', + 'password' => 'password', + 'name' => 'Test User', + ]); + + $this->assertEquals(201, $user1['headers']['status-code']); + + $user2 = $this->client->call(Client::METHOD_POST, '/users', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project4['body']['$id'], + 'x-appwrite-key' => $key4['body']['secret'] + ], [ + 'userId' => 'user', + 'email' => 'test@appwrite.io', + 'password' => 'password', + 'name' => 'Test User', + ]); + + $this->assertEquals(201, $user2['headers']['status-code']); + } }