diff --git a/assets/config.php b/assets/config.php index 0e035953..82a4c722 100644 --- a/assets/config.php +++ b/assets/config.php @@ -98,6 +98,11 @@ return [ */ 'template_tenant_connection' => null, + /** + * The name of the temporary connection used for creating and deleting tenant databases. + */ + 'tenant_host_connection_name' => 'tenant_host_connection', + /** * Tenant database names are created like this: * prefix + tenant_id + suffix. diff --git a/src/Database/DatabaseConfig.php b/src/Database/DatabaseConfig.php index 6c4df0d8..309d828f 100644 --- a/src/Database/DatabaseConfig.php +++ b/src/Database/DatabaseConfig.php @@ -5,10 +5,14 @@ declare(strict_types=1); namespace Stancl\Tenancy\Database; use Closure; +use Illuminate\Database; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase as Tenant; +use Stancl\Tenancy\Database\Exceptions\DatabaseManagerNotRegisteredException; +use Stancl\Tenancy\Database\Exceptions\NoConnectionSetException; class DatabaseConfig { @@ -83,7 +87,7 @@ class DatabaseConfig { $this->tenant->setInternal('db_name', $this->getName()); - if ($this->manager() instanceof Contracts\ManagesDatabaseUsers) { + if ($this->connectionDriverManager($this->getTemplateConnectionName()) instanceof Contracts\ManagesDatabaseUsers) { $this->tenant->setInternal('db_username', $this->getUsername() ?? (static::$usernameGenerator)($this->tenant)); $this->tenant->setInternal('db_password', $this->getPassword() ?? (static::$passwordGenerator)($this->tenant)); } @@ -100,6 +104,11 @@ class DatabaseConfig ?? config('tenancy.database.central_connection'); } + public function getTenantHostConnectionName(): string + { + return config('tenancy.database.tenant_host_connection_name', 'tenant_host_connection'); + } + /** * Tenant's own database connection config. */ @@ -114,6 +123,40 @@ class DatabaseConfig ); } + /** + * Tenant's host database connection config. + */ + public function hostConnection(): array + { + $config = $this->tenantConfig(); + $template = $this->getTemplateConnectionName(); + $templateConnection = config("database.connections.{$template}"); + + if ($this->connectionDriverManager($template) instanceof Contracts\ManagesDatabaseUsers) { + // We're removing the username and password because user with these credentials is not created yet + // If you need to provide username and password when using PermissionControlledMySQLDatabaseManager, + // consider creating a new connection and use it as `tenancy_db_connection` tenant config key + unset($config['username'], $config['password']); + } + + if (! $config) { + return $templateConnection; + } + + return array_replace($templateConnection, $config); + } + + /** + * Purge host database connection. + * + * It's possible database has previous tenant connection. + * This will clean up the previous connection before creating it for the current tenant. + */ + public function purgeHostConnection(): void + { + DB::purge($this->getTenantHostConnectionName()); + } + /** * Additional config for the database connection, specific to this tenant. */ @@ -140,10 +183,37 @@ class DatabaseConfig }, []); } - /** Get the TenantDatabaseManager for this tenant's connection. */ + /** Get the TenantDatabaseManager for this tenant's connection. + * + * @throws NoConnectionSetException|DatabaseManagerNotRegisteredException + */ public function manager(): Contracts\TenantDatabaseManager { - $driver = config("database.connections.{$this->getTemplateConnectionName()}.driver"); + // Laravel caches the previous PDO connection, so we purge it to be able to change the connection details + $this->purgeHostConnection(); // todo come up with a better name + + // Create the tenant host connection config + $tenantHostConnectionName = $this->getTenantHostConnectionName(); + config(["database.connections.{$tenantHostConnectionName}" => $this->hostConnection()]); + + $manager = $this->connectionDriverManager($tenantHostConnectionName); + + if ($manager instanceof Contracts\StatefulTenantDatabaseManager) { + $manager->setConnection($tenantHostConnectionName); + } + + return $manager; + } + + /** + * todo come up with a better name + * Get database manager class from the given connection config's driver. + * + * @throws DatabaseManagerNotRegisteredException + */ + protected function connectionDriverManager(string $connectionName): Contracts\TenantDatabaseManager + { + $driver = config("database.connections.{$connectionName}.driver"); $databaseManagers = config('tenancy.database.managers'); @@ -151,13 +221,6 @@ class DatabaseConfig throw new Exceptions\DatabaseManagerNotRegisteredException($driver); } - /** @var Contracts\TenantDatabaseManager $databaseManager */ - $databaseManager = app($databaseManagers[$driver]); - - if ($databaseManager instanceof Contracts\StatefulTenantDatabaseManager) { - $databaseManager->setConnection($this->getTemplateConnectionName()); - } - - return $databaseManager; + return app($databaseManagers[$driver]); } } diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index 33a3158f..19b74e21 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; @@ -52,7 +53,7 @@ test('databases can be created and deleted', function ($driver, $databaseManager expect($manager->databaseExists($name))->toBeTrue(); $manager->deleteDatabase($tenant); expect($manager->databaseExists($name))->toBeFalse(); -})->with('database_manager_provider'); +})->with('database_managers'); test('dbs can be created when another driver is used for the central db', function () { expect(config('database.default'))->toBe('central'); @@ -104,7 +105,7 @@ test('the tenant connection is fully removed', function () { $tenant = Tenant::create(); - expect(array_keys(app('db')->getConnections()))->toBe(['central']); + expect(array_keys(app('db')->getConnections()))->toBe(['central', 'tenant_host_connection']); pest()->assertArrayNotHasKey('tenant', config('database.connections')); tenancy()->initialize($tenant); @@ -183,7 +184,7 @@ test('a tenants database cannot be created when the database already exists', fu ]); }); -test('tenant database can be created on a foreign server', function () { +test('tenant database can be created and deleted on a foreign server', function () { config([ 'tenancy.database.managers.mysql' => PermissionControlledMySQLDatabaseManager::class, 'database.connections.mysql2' => [ @@ -219,10 +220,151 @@ test('tenant database can be created on a foreign server', function () { /** @var PermissionControlledMySQLDatabaseManager $manager */ $manager = $tenant->database()->manager(); - $manager->setConnection('mysql'); - expect($manager->databaseExists($name))->toBeFalse(); + expect($manager->databaseExists($name))->toBeTrue(); // mysql2 - $manager->setConnection('mysql2'); + $manager->setConnection('mysql'); + expect($manager->databaseExists($name))->toBeFalse(); // check that the DB doesn't exist in 'mysql' + + $manager->setConnection('mysql2'); // set the connection back + $manager->deleteDatabase($tenant); + + expect($manager->databaseExists($name))->toBeFalse(); +}); + +test('tenant database can be created on a foreign server by using the host from tenant config', function () { + config([ + 'tenancy.database.managers.mysql' => MySQLDatabaseManager::class, + 'tenancy.database.template_tenant_connection' => 'mysql', // This will be overridden by tenancy_db_host + 'database.connections.mysql2' => [ + 'driver' => 'mysql', + 'host' => 'mysql2', + 'port' => 3306, + 'database' => 'main', + 'username' => 'root', + 'password' => 'password', + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + ]); + + Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { + return $event->tenant; + })->toListener()); + + $name = 'foo' . Str::random(8); + $tenant = Tenant::create([ + 'tenancy_db_name' => $name, + 'tenancy_db_host' => 'mysql2', + ]); + + /** @var MySQLDatabaseManager $manager */ + $manager = $tenant->database()->manager(); + + expect($manager->databaseExists($name))->toBeTrue(); +}); + +test('database credentials can be provided to PermissionControlledMySQLDatabaseManager by specifying a connection', function () { + config([ + 'tenancy.database.managers.mysql' => PermissionControlledMySQLDatabaseManager::class, + 'tenancy.database.template_tenant_connection' => 'mysql', + 'database.connections.mysql2' => [ + 'driver' => 'mysql', + 'host' => 'mysql2', + 'port' => 3306, + 'database' => 'main', + 'username' => 'root', + 'password' => 'password', + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + ]); + + // Create a new random database user with privileges to use with mysql2 connection + $username = 'dbuser' . Str::random(4); + $password = Str::random('8'); + $mysql2DB = DB::connection('mysql2'); + $mysql2DB->statement("CREATE USER `{$username}`@`%` IDENTIFIED BY '{$password}';"); + $mysql2DB->statement("GRANT ALL PRIVILEGES ON *.* TO `{$username}`@`%` identified by '{$password}' WITH GRANT OPTION;"); + $mysql2DB->statement("FLUSH PRIVILEGES;"); + + DB::purge('mysql2'); // forget the mysql2 connection so that it uses the new credentials the next time + + config(['database.connections.mysql2.username' => $username]); + config(['database.connections.mysql2.password' => $password]); + + Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { + return $event->tenant; + })->toListener()); + + $name = 'foo' . Str::random(8); + $usernameForNewDB = 'user_for_new_db' . Str::random(4); + $passwordForNewDB = Str::random(8); + $tenant = Tenant::create([ + 'tenancy_db_name' => $name, + 'tenancy_db_connection' => 'mysql2', + 'tenancy_db_username' => $usernameForNewDB, + 'tenancy_db_password' => $passwordForNewDB, + ]); + + /** @var PermissionControlledMySQLDatabaseManager $manager */ + $manager = $tenant->database()->manager(); + + expect($manager->database()->getConfig('username'))->toBe($username); // user created for the HOST connection + expect($manager->userExists($usernameForNewDB))->toBeTrue(); + expect($manager->databaseExists($name))->toBeTrue(); +}); + +test('tenant database can be created by using the username and password from tenant config', function () { + Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { + return $event->tenant; + })->toListener()); + + config([ + 'tenancy.database.managers.mysql' => MySQLDatabaseManager::class, + 'tenancy.database.template_tenant_connection' => 'mysql', + ]); + + // Create a new random database user with privileges to use with `mysql` connection + $username = 'dbuser' . Str::random(4); + $password = Str::random('8'); + $mysqlDB = DB::connection('mysql'); + $mysqlDB->statement("CREATE USER `{$username}`@`%` IDENTIFIED BY '{$password}';"); + $mysqlDB->statement("GRANT ALL PRIVILEGES ON *.* TO `{$username}`@`%` identified by '{$password}' WITH GRANT OPTION;"); + $mysqlDB->statement("FLUSH PRIVILEGES;"); + + DB::purge('mysql2'); // forget the mysql2 connection so that it uses the new credentials the next time + + // Remove `mysql` credentials to make sure we will be using the credentials from the tenant config + config(['database.connections.mysql.username' => null]); + config(['database.connections.mysql.password' => null]); + + $name = 'foo' . Str::random(8); + $tenant = Tenant::create([ + 'tenancy_db_name' => $name, + 'tenancy_db_username' => $username, + 'tenancy_db_password' => $password, + ]); + + /** @var MySQLDatabaseManager $manager */ + $manager = $tenant->database()->manager(); + + expect($manager->database()->getConfig('username'))->toBe($username); // user created for the HOST connection expect($manager->databaseExists($name))->toBeTrue(); }); @@ -245,11 +387,11 @@ test('path used by sqlite manager can be customized', function () { 'tenancy_db_connection' => 'sqlite', ]); - expect(file_exists( $customPath . '/' . $name))->toBeTrue(); + expect(file_exists($customPath . '/' . $name))->toBeTrue(); }); // Datasets -dataset('database_manager_provider', [ +dataset('database_managers', [ ['mysql', MySQLDatabaseManager::class], ['mysql', PermissionControlledMySQLDatabaseManager::class], ['sqlite', SQLiteDatabaseManager::class],