From 808f52765c669f5d5bb78a2a599a248b29ec4acc Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 29 Apr 2026 10:08:45 +0200 Subject: [PATCH 01/97] Use select() instead of selectOne() in databaseExists() and userExists() This is just for consistency, since all the other DB managers use select(). --- src/Database/Concerns/ManagesPostgresUsers.php | 2 +- .../TenantDatabaseManagers/PostgreSQLDatabaseManager.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Database/Concerns/ManagesPostgresUsers.php b/src/Database/Concerns/ManagesPostgresUsers.php index bec94f49..a749469e 100644 --- a/src/Database/Concerns/ManagesPostgresUsers.php +++ b/src/Database/Concerns/ManagesPostgresUsers.php @@ -77,6 +77,6 @@ trait ManagesPostgresUsers public function userExists(string $username): bool { - return (bool) $this->connection()->selectOne("SELECT usename FROM pg_user WHERE usename = '{$username}'"); + return (bool) $this->connection()->select("SELECT usename FROM pg_user WHERE usename = '{$username}'"); } } diff --git a/src/Database/TenantDatabaseManagers/PostgreSQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/PostgreSQLDatabaseManager.php index 4fff7202..82a5963f 100644 --- a/src/Database/TenantDatabaseManagers/PostgreSQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/PostgreSQLDatabaseManager.php @@ -20,6 +20,6 @@ class PostgreSQLDatabaseManager extends TenantDatabaseManager public function databaseExists(string $name): bool { - return (bool) $this->connection()->selectOne("SELECT datname FROM pg_database WHERE datname = '$name'"); + return (bool) $this->connection()->select("SELECT datname FROM pg_database WHERE datname = '$name'"); } } From ad7d229dafd087325a70e020cfefe37be403fb44 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 29 Apr 2026 10:21:47 +0200 Subject: [PATCH 02/97] Use parameter binding in SELECT queries --- src/Database/Concerns/ManagesPostgresUsers.php | 2 +- .../TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php | 2 +- .../TenantDatabaseManagers/MySQLDatabaseManager.php | 2 +- ...ermissionControlledMicrosoftSQLServerDatabaseManager.php | 2 +- .../PermissionControlledMySQLDatabaseManager.php | 2 +- .../PermissionControlledPostgreSQLSchemaManager.php | 6 +++--- .../TenantDatabaseManagers/PostgreSQLDatabaseManager.php | 2 +- .../TenantDatabaseManagers/PostgreSQLSchemaManager.php | 2 +- 8 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/Database/Concerns/ManagesPostgresUsers.php b/src/Database/Concerns/ManagesPostgresUsers.php index a749469e..25b365c6 100644 --- a/src/Database/Concerns/ManagesPostgresUsers.php +++ b/src/Database/Concerns/ManagesPostgresUsers.php @@ -77,6 +77,6 @@ trait ManagesPostgresUsers public function userExists(string $username): bool { - return (bool) $this->connection()->select("SELECT usename FROM pg_user WHERE usename = '{$username}'"); + return (bool) $this->connection()->select("SELECT usename FROM pg_user WHERE usename = ?", [$username]); } } diff --git a/src/Database/TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php index da993956..25551f91 100644 --- a/src/Database/TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php @@ -22,6 +22,6 @@ class MicrosoftSQLDatabaseManager extends TenantDatabaseManager public function databaseExists(string $name): bool { - return (bool) $this->connection()->select("SELECT name FROM master.sys.databases WHERE name = '$name'"); + return (bool) $this->connection()->select("SELECT name FROM master.sys.databases WHERE name = ?", [$name]); } } diff --git a/src/Database/TenantDatabaseManagers/MySQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/MySQLDatabaseManager.php index b86faef2..c798d12f 100644 --- a/src/Database/TenantDatabaseManagers/MySQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/MySQLDatabaseManager.php @@ -24,6 +24,6 @@ class MySQLDatabaseManager extends TenantDatabaseManager public function databaseExists(string $name): bool { - return (bool) $this->connection()->select("SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = '$name'"); + return (bool) $this->connection()->select("SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = ?", [$name]); } } diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledMicrosoftSQLServerDatabaseManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledMicrosoftSQLServerDatabaseManager.php index b373f41e..5e78fce2 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledMicrosoftSQLServerDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledMicrosoftSQLServerDatabaseManager.php @@ -42,7 +42,7 @@ class PermissionControlledMicrosoftSQLServerDatabaseManager extends MicrosoftSQL public function userExists(string $username): bool { - return (bool) $this->connection()->select("SELECT sp.name as username FROM sys.server_principals sp WHERE sp.name = '{$username}'"); + return (bool) $this->connection()->select("SELECT sp.name as username FROM sys.server_principals sp WHERE sp.name = ?", [$username]); } public function makeConnectionConfig(array $baseConfig, string $databaseName): array diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php index 47ec11a2..09040725 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php @@ -53,6 +53,6 @@ class PermissionControlledMySQLDatabaseManager extends MySQLDatabaseManager impl public function userExists(string $username): bool { - return (bool) $this->connection()->select("SELECT count(*) FROM mysql.user WHERE user = '$username'")[0]->{'count(*)'}; + return (bool) $this->connection()->select("SELECT count(*) FROM mysql.user WHERE user = ?", [$username])[0]->{'count(*)'}; } } diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php index b528d4e3..fbf02d5a 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php @@ -27,7 +27,7 @@ class PermissionControlledPostgreSQLSchemaManager extends PostgreSQLSchemaManage $this->connection()->statement("GRANT USAGE, CREATE ON SCHEMA \"{$schema}\" TO \"{$username}\""); $this->connection()->statement("GRANT USAGE ON ALL SEQUENCES IN SCHEMA \"{$schema}\" TO \"{$username}\""); - $tables = $this->connection()->select("SELECT table_name FROM information_schema.tables WHERE table_schema = '{$schema}' AND table_type = 'BASE TABLE'"); + $tables = $this->connection()->select("SELECT table_name FROM information_schema.tables WHERE table_schema = ? AND table_type = 'BASE TABLE'", [$schema]); // Grant permissions to any existing tables. This is used with RLS foreach ($tables as $table) { @@ -37,9 +37,9 @@ class PermissionControlledPostgreSQLSchemaManager extends PostgreSQLSchemaManage $primaryKey = $this->connection()->selectOne(<<column_name; + SQL, [$tableName])->column_name; // Grant all permissions for all existing tables $this->connection()->statement("GRANT ALL ON \"{$schema}\".\"{$tableName}\" TO \"{$username}\""); diff --git a/src/Database/TenantDatabaseManagers/PostgreSQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/PostgreSQLDatabaseManager.php index 82a5963f..7338306e 100644 --- a/src/Database/TenantDatabaseManagers/PostgreSQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/PostgreSQLDatabaseManager.php @@ -20,6 +20,6 @@ class PostgreSQLDatabaseManager extends TenantDatabaseManager public function databaseExists(string $name): bool { - return (bool) $this->connection()->select("SELECT datname FROM pg_database WHERE datname = '$name'"); + return (bool) $this->connection()->select("SELECT datname FROM pg_database WHERE datname = ?", [$name]); } } diff --git a/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php b/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php index d0fb0337..af69df92 100644 --- a/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php +++ b/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php @@ -20,7 +20,7 @@ class PostgreSQLSchemaManager extends TenantDatabaseManager public function databaseExists(string $name): bool { - return (bool) $this->connection()->select("SELECT schema_name FROM information_schema.schemata WHERE schema_name = '$name'"); + return (bool) $this->connection()->select("SELECT schema_name FROM information_schema.schemata WHERE schema_name = ?", [$name]); } public function makeConnectionConfig(array $baseConfig, string $databaseName): array From bdf592c0ff6cba0e7eb7643781fe4b078f4d5c36 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 29 Apr 2026 14:13:56 +0200 Subject: [PATCH 03/97] Add parameter validation to DB managers DB manager methods validate the parameters they use in SQL statements using validateParameter() (excluding parameters passed via bindings in SELECT statements). --- .../Concerns/ManagesPostgresUsers.php | 5 +++- .../MicrosoftSQLDatabaseManager.php | 6 +++-- .../MySQLDatabaseManager.php | 6 ++++- ...olledMicrosoftSQLServerDatabaseManager.php | 11 +++++++-- ...rmissionControlledMySQLDatabaseManager.php | 7 +++++- ...ionControlledPostgreSQLDatabaseManager.php | 2 ++ ...ssionControlledPostgreSQLSchemaManager.php | 2 ++ .../PostgreSQLDatabaseManager.php | 8 +++++-- .../PostgreSQLSchemaManager.php | 8 +++++-- .../TenantDatabaseManager.php | 23 +++++++++++++++++++ 10 files changed, 67 insertions(+), 11 deletions(-) diff --git a/src/Database/Concerns/ManagesPostgresUsers.php b/src/Database/Concerns/ManagesPostgresUsers.php index 25b365c6..6abe6cea 100644 --- a/src/Database/Concerns/ManagesPostgresUsers.php +++ b/src/Database/Concerns/ManagesPostgresUsers.php @@ -28,6 +28,9 @@ trait ManagesPostgresUsers $username = $databaseConfig->getUsername(); $password = $databaseConfig->getPassword(); + // todo@validation password + $this->validateParameter($username); + $createUser = ! $this->userExists($username); if ($createUser) { @@ -42,7 +45,7 @@ trait ManagesPostgresUsers public function deleteUser(DatabaseConfig $databaseConfig): bool { // Tenant DB username - $username = $databaseConfig->getUsername(); + $username = $this->validateParameter($databaseConfig->getUsername()); // Tenant host connection config $connectionName = $this->connection()->getConfig('name'); diff --git a/src/Database/TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php index 25551f91..df3ecb60 100644 --- a/src/Database/TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php @@ -10,14 +10,16 @@ class MicrosoftSQLDatabaseManager extends TenantDatabaseManager { public function createDatabase(TenantWithDatabase $tenant): bool { - $database = $tenant->database()->getName(); + $database = $this->validateParameter($tenant->database()->getName()); return $this->connection()->statement("CREATE DATABASE [{$database}]"); } public function deleteDatabase(TenantWithDatabase $tenant): bool { - return $this->connection()->statement("DROP DATABASE [{$tenant->database()->getName()}]"); + $database = $this->validateParameter($tenant->database()->getName()); + + return $this->connection()->statement("DROP DATABASE [{$database}]"); } public function databaseExists(string $name): bool diff --git a/src/Database/TenantDatabaseManagers/MySQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/MySQLDatabaseManager.php index c798d12f..34978a82 100644 --- a/src/Database/TenantDatabaseManagers/MySQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/MySQLDatabaseManager.php @@ -14,12 +14,16 @@ class MySQLDatabaseManager extends TenantDatabaseManager $charset = $this->connection()->getConfig('charset'); $collation = $this->connection()->getConfig('collation'); + $this->validateParameter([$database, $charset, $collation]); + return $this->connection()->statement("CREATE DATABASE `{$database}` CHARACTER SET `$charset` COLLATE `$collation`"); } public function deleteDatabase(TenantWithDatabase $tenant): bool { - return $this->connection()->statement("DROP DATABASE `{$tenant->database()->getName()}`"); + $database = $this->validateParameter($tenant->database()->getName()); + + return $this->connection()->statement("DROP DATABASE `{$database}`"); } public function databaseExists(string $name): bool diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledMicrosoftSQLServerDatabaseManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledMicrosoftSQLServerDatabaseManager.php index 5e78fce2..69fb77b2 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledMicrosoftSQLServerDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledMicrosoftSQLServerDatabaseManager.php @@ -24,6 +24,9 @@ class PermissionControlledMicrosoftSQLServerDatabaseManager extends MicrosoftSQL $username = $databaseConfig->getUsername(); $password = $databaseConfig->getPassword(); + // todo@validation password + $this->validateParameter([$database, $username]); + // Create login $this->connection()->statement("CREATE LOGIN [$username] WITH PASSWORD = '$password'"); @@ -37,7 +40,9 @@ class PermissionControlledMicrosoftSQLServerDatabaseManager extends MicrosoftSQL public function deleteUser(DatabaseConfig $databaseConfig): bool { - return $this->connection()->statement("DROP LOGIN [{$databaseConfig->getUsername()}]"); + $username = $this->validateParameter($databaseConfig->getUsername()); + + return $this->connection()->statement("DROP LOGIN [{$username}]"); } public function userExists(string $username): bool @@ -54,11 +59,13 @@ class PermissionControlledMicrosoftSQLServerDatabaseManager extends MicrosoftSQL public function deleteDatabase(TenantWithDatabase $tenant): bool { + $name = $this->validateParameter($tenant->database()->getName()); + // Close all connections to the database before deleting it // Set the database to SINGLE_USER mode to ensure that // No other connections are using the database while we're trying to delete it // Rollback all active transactions - $this->connection()->statement("ALTER DATABASE [{$tenant->database()->getName()}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;"); + $this->connection()->statement("ALTER DATABASE [{$name}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;"); return parent::deleteDatabase($tenant); } diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php index 09040725..d5b64205 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php @@ -25,6 +25,9 @@ class PermissionControlledMySQLDatabaseManager extends MySQLDatabaseManager impl $username = $databaseConfig->getUsername(); $password = $databaseConfig->getPassword(); + //todo@validation password + $this->validateParameter([$database, $username]); + $this->connection()->statement("CREATE USER `{$username}`@`%` IDENTIFIED BY '{$password}'"); $grants = implode(', ', static::$grants); @@ -48,7 +51,9 @@ class PermissionControlledMySQLDatabaseManager extends MySQLDatabaseManager impl public function deleteUser(DatabaseConfig $databaseConfig): bool { - return $this->connection()->statement("DROP USER IF EXISTS '{$databaseConfig->getUsername()}'"); + $username = $this->validateParameter($databaseConfig->getUsername()); + + return $this->connection()->statement("DROP USER IF EXISTS '{$username}'"); } public function userExists(string $username): bool diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLDatabaseManager.php index 1522234e..a10cfd2e 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLDatabaseManager.php @@ -20,6 +20,8 @@ class PermissionControlledPostgreSQLDatabaseManager extends PostgreSQLDatabaseMa $username = $databaseConfig->getUsername(); $schema = $databaseConfig->connection()['search_path']; + $this->validateParameter([$database, $username, $schema]); + // Host config $connectionName = $this->connection()->getConfig('name'); $centralDatabase = $this->connection()->getConfig('database'); diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php index fbf02d5a..69773a3b 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php @@ -23,6 +23,8 @@ class PermissionControlledPostgreSQLSchemaManager extends PostgreSQLSchemaManage // Central database name $database = DB::connection(config('tenancy.database.central_connection'))->getDatabaseName(); + $this->validateParameter([$username, $schema, $database]); + $this->connection()->statement("GRANT CONNECT ON DATABASE {$database} TO \"{$username}\""); $this->connection()->statement("GRANT USAGE, CREATE ON SCHEMA \"{$schema}\" TO \"{$username}\""); $this->connection()->statement("GRANT USAGE ON ALL SEQUENCES IN SCHEMA \"{$schema}\" TO \"{$username}\""); diff --git a/src/Database/TenantDatabaseManagers/PostgreSQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/PostgreSQLDatabaseManager.php index 7338306e..1724d470 100644 --- a/src/Database/TenantDatabaseManagers/PostgreSQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/PostgreSQLDatabaseManager.php @@ -10,12 +10,16 @@ class PostgreSQLDatabaseManager extends TenantDatabaseManager { public function createDatabase(TenantWithDatabase $tenant): bool { - return $this->connection()->statement("CREATE DATABASE \"{$tenant->database()->getName()}\" WITH TEMPLATE=template0"); + $name = $this->validateParameter($tenant->database()->getName()); + + return $this->connection()->statement("CREATE DATABASE \"{$name}\" WITH TEMPLATE=template0"); } public function deleteDatabase(TenantWithDatabase $tenant): bool { - return $this->connection()->statement("DROP DATABASE \"{$tenant->database()->getName()}\""); + $name = $this->validateParameter($tenant->database()->getName()); + + return $this->connection()->statement("DROP DATABASE \"{$name}\""); } public function databaseExists(string $name): bool diff --git a/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php b/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php index af69df92..b3dbfb9e 100644 --- a/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php +++ b/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php @@ -10,12 +10,16 @@ class PostgreSQLSchemaManager extends TenantDatabaseManager { public function createDatabase(TenantWithDatabase $tenant): bool { - return $this->connection()->statement("CREATE SCHEMA \"{$tenant->database()->getName()}\""); + $name = $this->validateParameter($tenant->database()->getName()); + + return $this->connection()->statement("CREATE SCHEMA \"{$name}\""); } public function deleteDatabase(TenantWithDatabase $tenant): bool { - return $this->connection()->statement("DROP SCHEMA \"{$tenant->database()->getName()}\" CASCADE"); + $name = $this->validateParameter($tenant->database()->getName()); + + return $this->connection()->statement("DROP SCHEMA \"{$name}\" CASCADE"); } public function databaseExists(string $name): bool diff --git a/src/Database/TenantDatabaseManagers/TenantDatabaseManager.php b/src/Database/TenantDatabaseManagers/TenantDatabaseManager.php index 3d8d7610..c1d7b4fb 100644 --- a/src/Database/TenantDatabaseManagers/TenantDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/TenantDatabaseManager.php @@ -6,11 +6,15 @@ namespace Stancl\Tenancy\Database\TenantDatabaseManagers; use Illuminate\Database\Connection; use Illuminate\Support\Facades\DB; +use InvalidArgumentException; use Stancl\Tenancy\Database\Contracts\StatefulTenantDatabaseManager; use Stancl\Tenancy\Database\Exceptions\NoConnectionSetException; abstract class TenantDatabaseManager implements StatefulTenantDatabaseManager { + /** Characters allowed in SQL identifiers (database names, usernames, schema names, etc.). */ + public static string $allowlist = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-'; + /** The database connection to the server. */ protected string $connection; @@ -34,4 +38,23 @@ abstract class TenantDatabaseManager implements StatefulTenantDatabaseManager return $baseConfig; } + + /** + * Validate that parameters (database names, usernames, etc.) + * contain only allowed characters before used in SQL statements. + * + * @throws InvalidArgumentException + */ + protected function validateParameter(string|array $parameters): string|array + { + foreach ((array) $parameters as $parameter) { + foreach (str_split($parameter) as $char) { + if (! str_contains(static::$allowlist, $char)) { + throw new InvalidArgumentException("Invalid character '{$char}' in SQL parameter: {$parameter}"); + } + } + } + + return $parameters; + } } From 5adbc14a7e3c1dc3dc997f8a7a7cb19a5fea64a0 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 29 Apr 2026 14:16:00 +0200 Subject: [PATCH 04/97] Test SQL parameter validation WIP: password validation, SQLite (check if validation is enough for valid FS paths), revisit the test --- tests/TenantDatabaseManagerTest.php | 73 +++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index 0d83e70e..9c5ff41a 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -17,6 +17,7 @@ use Stancl\Tenancy\Events\TenancyInitialized; use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; +use Stancl\Tenancy\Database\Contracts\ManagesDatabaseUsers; use Stancl\Tenancy\Database\Contracts\StatefulTenantDatabaseManager; use Stancl\Tenancy\Database\TenantDatabaseManagers\MySQLDatabaseManager; use Stancl\Tenancy\Database\TenantDatabaseManagers\SQLiteDatabaseManager; @@ -539,6 +540,78 @@ test('partial tenant connection templates get merged into the central connection expect($manager->connection()->getConfig('url'))->toBeNull(); }); +test('database managers validate sql parameters before using them in statements', function ($driver, $databaseManager) { + // todo@validation passwords. also sqlite? + if ($driver === 'sqlite') { + $this->markTestSkipped('SQLiteDatabaseManager does not use SQL statements.'); + } + + config()->set([ + "tenancy.database.template_tenant_connection" => $driver, + ]); + + $manager = app($databaseManager); + + if ($manager instanceof StatefulTenantDatabaseManager) { + $manager->setConnection($driver); + } + + if (! $manager instanceof ManagesDatabaseUsers) { + // Test that the createDatabase() and deleteDatabase() methods validate the database name. + // Only test non-permission controlled managers here since permission controlled managers + // override these methods to e.g. delete users before calling parent::deleteDatabase(), + // and with invalid DB name, the user deletion will already fail before we even get to actual + // deleteDatabase() logic. + $tenant = new Tenant([ + 'tenancy_db_name' => $invalidDatabaseName = "\"database_with_quotes\"", + 'tenancy_db_username' => 'valid-username', + ]); + + expect(fn () => $manager->createDatabase($tenant)) + ->toThrow(InvalidArgumentException::class, $invalidDatabaseName); + + expect(fn () => $manager->deleteDatabase($tenant)) + ->toThrow(InvalidArgumentException::class, $invalidDatabaseName); + } + + if ($manager instanceof ManagesDatabaseUsers) { + // Valid database name but invalid username + // to ensure that createUser() and deleteUser() validate the username. + $tenantWithInvalidUsername = new Tenant([ + 'tenancy_db_name' => 'valid_database_name890', + 'tenancy_db_username' => $invalidUsername = "username with spaces", + ]); + + expect(fn () => $manager->createUser($tenantWithInvalidUsername->database())) + ->toThrow(InvalidArgumentException::class, $invalidUsername); + + expect(fn () => $manager->deleteUser($tenantWithInvalidUsername->database())) + ->toThrow(InvalidArgumentException::class, $invalidUsername); + + // Valid username but invalid database name + // to ensure that createUser() validates the database name. + // + // grantPermissions() called in createUser() also validates DB and user names, + // but with the current implementation, if these parameters are + // invalid in createUser(), grantPermissions() will never be reached. + $tenantWithInvalidDatabase = new Tenant([ + 'tenancy_db_name' => $invalidDatabaseName = 'db/with/slashes', + 'tenancy_db_username' => 'valid_USERNAME', + ]); + + expect(fn () => $manager->createUser($tenantWithInvalidDatabase->database())) + ->toThrow(InvalidArgumentException::class, $invalidDatabaseName); + } + + $validTenant = new Tenant([ + 'tenancy_db_name' => 'VALID-db-name456', + 'tenancy_db_username' => 'valid_USERNAME123', + ]); + + expect(fn () => $manager->createDatabase($validTenant))->not()->toThrow(InvalidArgumentException::class); + expect(fn () => $manager->deleteDatabase($validTenant))->not()->toThrow(InvalidArgumentException::class); +})->with('database_managers'); + // Datasets dataset('database_managers', [ ['mysql', MySQLDatabaseManager::class], From 182f3a2eb2a1321c7d081595d788169b00372e1a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 29 Apr 2026 12:16:22 +0000 Subject: [PATCH 05/97] Fix code style (php-cs-fixer) --- src/Database/Concerns/ManagesPostgresUsers.php | 2 +- .../TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php | 2 +- src/Database/TenantDatabaseManagers/MySQLDatabaseManager.php | 2 +- .../PermissionControlledMicrosoftSQLServerDatabaseManager.php | 2 +- .../PermissionControlledMySQLDatabaseManager.php | 2 +- .../PermissionControlledPostgreSQLSchemaManager.php | 2 +- .../TenantDatabaseManagers/PostgreSQLDatabaseManager.php | 2 +- src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Database/Concerns/ManagesPostgresUsers.php b/src/Database/Concerns/ManagesPostgresUsers.php index 6abe6cea..09632f9a 100644 --- a/src/Database/Concerns/ManagesPostgresUsers.php +++ b/src/Database/Concerns/ManagesPostgresUsers.php @@ -80,6 +80,6 @@ trait ManagesPostgresUsers public function userExists(string $username): bool { - return (bool) $this->connection()->select("SELECT usename FROM pg_user WHERE usename = ?", [$username]); + return (bool) $this->connection()->select('SELECT usename FROM pg_user WHERE usename = ?', [$username]); } } diff --git a/src/Database/TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php index df3ecb60..e40f9e6e 100644 --- a/src/Database/TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php @@ -24,6 +24,6 @@ class MicrosoftSQLDatabaseManager extends TenantDatabaseManager public function databaseExists(string $name): bool { - return (bool) $this->connection()->select("SELECT name FROM master.sys.databases WHERE name = ?", [$name]); + return (bool) $this->connection()->select('SELECT name FROM master.sys.databases WHERE name = ?', [$name]); } } diff --git a/src/Database/TenantDatabaseManagers/MySQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/MySQLDatabaseManager.php index 34978a82..605db7de 100644 --- a/src/Database/TenantDatabaseManagers/MySQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/MySQLDatabaseManager.php @@ -28,6 +28,6 @@ class MySQLDatabaseManager extends TenantDatabaseManager public function databaseExists(string $name): bool { - return (bool) $this->connection()->select("SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = ?", [$name]); + return (bool) $this->connection()->select('SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = ?', [$name]); } } diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledMicrosoftSQLServerDatabaseManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledMicrosoftSQLServerDatabaseManager.php index 69fb77b2..ee37047b 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledMicrosoftSQLServerDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledMicrosoftSQLServerDatabaseManager.php @@ -47,7 +47,7 @@ class PermissionControlledMicrosoftSQLServerDatabaseManager extends MicrosoftSQL public function userExists(string $username): bool { - return (bool) $this->connection()->select("SELECT sp.name as username FROM sys.server_principals sp WHERE sp.name = ?", [$username]); + return (bool) $this->connection()->select('SELECT sp.name as username FROM sys.server_principals sp WHERE sp.name = ?', [$username]); } public function makeConnectionConfig(array $baseConfig, string $databaseName): array diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php index d5b64205..ad4d6583 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php @@ -58,6 +58,6 @@ class PermissionControlledMySQLDatabaseManager extends MySQLDatabaseManager impl public function userExists(string $username): bool { - return (bool) $this->connection()->select("SELECT count(*) FROM mysql.user WHERE user = ?", [$username])[0]->{'count(*)'}; + return (bool) $this->connection()->select('SELECT count(*) FROM mysql.user WHERE user = ?', [$username])[0]->{'count(*)'}; } } diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php index 69773a3b..9fc66c6f 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php @@ -36,7 +36,7 @@ class PermissionControlledPostgreSQLSchemaManager extends PostgreSQLSchemaManage $tableName = $table->table_name; /** @var string $primaryKey */ - $primaryKey = $this->connection()->selectOne(<<connection()->selectOne(<<<'SQL' SELECT column_name FROM information_schema.key_column_usage WHERE table_name = ? diff --git a/src/Database/TenantDatabaseManagers/PostgreSQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/PostgreSQLDatabaseManager.php index 1724d470..e1219e8c 100644 --- a/src/Database/TenantDatabaseManagers/PostgreSQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/PostgreSQLDatabaseManager.php @@ -24,6 +24,6 @@ class PostgreSQLDatabaseManager extends TenantDatabaseManager public function databaseExists(string $name): bool { - return (bool) $this->connection()->select("SELECT datname FROM pg_database WHERE datname = ?", [$name]); + return (bool) $this->connection()->select('SELECT datname FROM pg_database WHERE datname = ?', [$name]); } } diff --git a/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php b/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php index b3dbfb9e..3ffbc858 100644 --- a/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php +++ b/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php @@ -24,7 +24,7 @@ class PostgreSQLSchemaManager extends TenantDatabaseManager public function databaseExists(string $name): bool { - return (bool) $this->connection()->select("SELECT schema_name FROM information_schema.schemata WHERE schema_name = ?", [$name]); + return (bool) $this->connection()->select('SELECT schema_name FROM information_schema.schemata WHERE schema_name = ?', [$name]); } public function makeConnectionConfig(array $baseConfig, string $databaseName): array From d5087d19c5021feb2a66a97471126e86f1ea7c3c Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 29 Apr 2026 15:54:13 +0200 Subject: [PATCH 06/97] Extract parameter validation into a trait Also, use parameterAllowlist() instead of the static property (so that we can e.g. override it later in SQLiteDatabaseManager, since overriding the static property doesn't work). --- .../Concerns/ValidatesSqlParameters.php | 39 +++++++++++++++++++ .../TenantDatabaseManager.php | 24 +----------- 2 files changed, 41 insertions(+), 22 deletions(-) create mode 100644 src/Database/Concerns/ValidatesSqlParameters.php diff --git a/src/Database/Concerns/ValidatesSqlParameters.php b/src/Database/Concerns/ValidatesSqlParameters.php new file mode 100644 index 00000000..970a0e71 --- /dev/null +++ b/src/Database/Concerns/ValidatesSqlParameters.php @@ -0,0 +1,39 @@ + Date: Wed, 29 Apr 2026 16:01:49 +0200 Subject: [PATCH 07/97] Validate SQLite DB names in create/deleteDatabase() Also stop skipping the validation test for sqlite. --- .../TenantDatabaseManagers/SQLiteDatabaseManager.php | 12 ++++++++++++ tests/TenantDatabaseManagerTest.php | 5 +---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php index 295cf304..e4e6ab76 100644 --- a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php @@ -7,12 +7,15 @@ namespace Stancl\Tenancy\Database\TenantDatabaseManagers; use Closure; use Illuminate\Database\Eloquent\Model; use PDO; +use Stancl\Tenancy\Database\Concerns\ValidatesSqlParameters; use Stancl\Tenancy\Database\Contracts\TenantDatabaseManager; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; use Throwable; class SQLiteDatabaseManager implements TenantDatabaseManager { + use ValidatesSqlParameters; + /** * SQLite database directory path. * @@ -57,6 +60,11 @@ class SQLiteDatabaseManager implements TenantDatabaseManager */ public static Closure|null $closeInMemoryConnectionUsing = null; + protected static function parameterAllowlist(): string + { + return 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.'; + } + public function createDatabase(TenantWithDatabase $tenant): bool { /** @var TenantWithDatabase&Model $tenant */ @@ -84,6 +92,8 @@ class SQLiteDatabaseManager implements TenantDatabaseManager return true; } + $this->validateParameter($name); + return file_put_contents($this->getPath($name), '') !== false; } @@ -99,6 +109,8 @@ class SQLiteDatabaseManager implements TenantDatabaseManager return true; } + $this->validateParameter($name); + $path = $this->getPath($name); try { diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index 9c5ff41a..14789a82 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -541,10 +541,7 @@ test('partial tenant connection templates get merged into the central connection }); test('database managers validate sql parameters before using them in statements', function ($driver, $databaseManager) { - // todo@validation passwords. also sqlite? - if ($driver === 'sqlite') { - $this->markTestSkipped('SQLiteDatabaseManager does not use SQL statements.'); - } + // todo@validation passwords config()->set([ "tenancy.database.template_tenant_connection" => $driver, From 0fdb8b2041b3dea91ee43f0e2e69304603787c94 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 29 Apr 2026 17:22:24 +0200 Subject: [PATCH 08/97] Validate user passwords in DB managers Also, make the validateParameter method ignore null parameters, e.g. for cases when tenants are created using Tenant::make() without tenancy_db_username set -- $databaseConfig->getUsername() allows null, same should go for the validate method whose only concern is checking strings for invalid characters. --- .../Concerns/ManagesPostgresUsers.php | 2 +- .../Concerns/ValidatesSqlParameters.php | 44 +++++++++++++++++-- ...olledMicrosoftSQLServerDatabaseManager.php | 2 +- ...rmissionControlledMySQLDatabaseManager.php | 2 +- 4 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/Database/Concerns/ManagesPostgresUsers.php b/src/Database/Concerns/ManagesPostgresUsers.php index 09632f9a..ddc03a34 100644 --- a/src/Database/Concerns/ManagesPostgresUsers.php +++ b/src/Database/Concerns/ManagesPostgresUsers.php @@ -28,8 +28,8 @@ trait ManagesPostgresUsers $username = $databaseConfig->getUsername(); $password = $databaseConfig->getPassword(); - // todo@validation password $this->validateParameter($username); + $this->validatePassword($password); $createUser = ! $this->userExists($username); diff --git a/src/Database/Concerns/ValidatesSqlParameters.php b/src/Database/Concerns/ValidatesSqlParameters.php index 970a0e71..ad61e8f4 100644 --- a/src/Database/Concerns/ValidatesSqlParameters.php +++ b/src/Database/Concerns/ValidatesSqlParameters.php @@ -18,22 +18,58 @@ trait ValidatesSqlParameters return 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-'; } + /** + * Characters allowed in database user passwords. + * + * Passwords are always quoted in the SQL statements, so it's safe + * to allow a wider range of characters, as long as it doesn't include + * characters that can break out of the quoted SQL strings (so e.g. + * ', ", \, and ` aren't allowed). + */ + protected static function passwordAllowlist(): string + { + return ' !#$%&()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]^_abcdefghijklmnopqrstuvwxyz{|}~'; + } + /** * Validate that parameters (database names, usernames, etc.) - * contain only allowed characters before used in SQL statements. + * only contain allowed characters before used in SQL statements. + * + * By default, only the characters in static::parameterAllowlist() are allowed. * * @throws InvalidArgumentException */ - protected function validateParameter(string|array $parameters): string|array + protected function validateParameter(string|array|null $parameters, string|null $allowlist = null): string|array|null { + if (is_null($parameters)) { + // Return null if there's nothing to validate + // (e.g. when $databaseConfig->getUsername() of an + // improperly created tenant is passed). + return null; + } + + $allowlist = $allowlist ?? static::parameterAllowlist(); + foreach ((array) $parameters as $parameter) { foreach (str_split($parameter) as $char) { - if (! str_contains(static::parameterAllowlist(), $char)) { - throw new InvalidArgumentException("Invalid character '{$char}' in SQL parameter: {$parameter}"); + if (! str_contains($allowlist, $char)) { + throw new InvalidArgumentException("Invalid character '{$char}' in parameter: {$parameter}"); } } } return $parameters; } + + /** + * Validate that a password only contains allowed characters before used in SQL statements. + * + * Used as a shorthand for validateParameter() with the less strict allowlist. + * + * @throws InvalidArgumentException + */ + protected function validatePassword(string|null $password): string|null + { + return $this->validateParameter($password, static::passwordAllowlist()); + } } diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledMicrosoftSQLServerDatabaseManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledMicrosoftSQLServerDatabaseManager.php index ee37047b..2671fcb5 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledMicrosoftSQLServerDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledMicrosoftSQLServerDatabaseManager.php @@ -24,8 +24,8 @@ class PermissionControlledMicrosoftSQLServerDatabaseManager extends MicrosoftSQL $username = $databaseConfig->getUsername(); $password = $databaseConfig->getPassword(); - // todo@validation password $this->validateParameter([$database, $username]); + $this->validatePassword($password); // Create login $this->connection()->statement("CREATE LOGIN [$username] WITH PASSWORD = '$password'"); diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php index ad4d6583..d8eb3734 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php @@ -25,8 +25,8 @@ class PermissionControlledMySQLDatabaseManager extends MySQLDatabaseManager impl $username = $databaseConfig->getUsername(); $password = $databaseConfig->getPassword(); - //todo@validation password $this->validateParameter([$database, $username]); + $this->validatePassword($password); $this->connection()->statement("CREATE USER `{$username}`@`%` IDENTIFIED BY '{$password}'"); From 4a3e6bae001d5062209211ee1b50d3a732874e93 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 29 Apr 2026 17:23:22 +0200 Subject: [PATCH 09/97] Test invalid passwords, improve test name and comments --- tests/TenantDatabaseManagerTest.php | 36 ++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index 14789a82..c4b514cb 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -540,9 +540,7 @@ test('partial tenant connection templates get merged into the central connection expect($manager->connection()->getConfig('url'))->toBeNull(); }); -test('database managers validate sql parameters before using them in statements', function ($driver, $databaseManager) { - // todo@validation passwords - +test('database managers validate parameters that cannot be bound', function ($driver, $databaseManager) { config()->set([ "tenancy.database.template_tenant_connection" => $driver, ]); @@ -559,7 +557,7 @@ test('database managers validate sql parameters before using them in statements' // override these methods to e.g. delete users before calling parent::deleteDatabase(), // and with invalid DB name, the user deletion will already fail before we even get to actual // deleteDatabase() logic. - $tenant = new Tenant([ + $tenant = Tenant::make([ 'tenancy_db_name' => $invalidDatabaseName = "\"database_with_quotes\"", 'tenancy_db_username' => 'valid-username', ]); @@ -572,9 +570,9 @@ test('database managers validate sql parameters before using them in statements' } if ($manager instanceof ManagesDatabaseUsers) { - // Valid database name but invalid username - // to ensure that createUser() and deleteUser() validate the username. - $tenantWithInvalidUsername = new Tenant([ + // Invalid username, createUser() and deleteUser() should + // throw an invalid argument exception. + $tenantWithInvalidUsername = Tenant::make([ 'tenancy_db_name' => 'valid_database_name890', 'tenancy_db_username' => $invalidUsername = "username with spaces", ]); @@ -585,27 +583,43 @@ test('database managers validate sql parameters before using them in statements' expect(fn () => $manager->deleteUser($tenantWithInvalidUsername->database())) ->toThrow(InvalidArgumentException::class, $invalidUsername); - // Valid username but invalid database name - // to ensure that createUser() validates the database name. + // Invalid database name, createUser() should throw + // an invalid argument exception. // // grantPermissions() called in createUser() also validates DB and user names, // but with the current implementation, if these parameters are // invalid in createUser(), grantPermissions() will never be reached. - $tenantWithInvalidDatabase = new Tenant([ + $tenantWithInvalidDatabase = Tenant::make([ 'tenancy_db_name' => $invalidDatabaseName = 'db/with/slashes', 'tenancy_db_username' => 'valid_USERNAME', ]); expect(fn () => $manager->createUser($tenantWithInvalidDatabase->database())) ->toThrow(InvalidArgumentException::class, $invalidDatabaseName); + + $tenantWithInvalidPassword = Tenant::make([ + 'tenancy_db_name' => 'valid_database_name890', + 'tenancy_db_username' => 'valid_USERNAME', + 'tenancy_db_password' => $invalidPassword = "p'ssword", + ]); + + expect(fn () => $manager->createUser($tenantWithInvalidPassword->database())) + ->toThrow(InvalidArgumentException::class, $invalidPassword); } - $validTenant = new Tenant([ + $validTenant = Tenant::make([ 'tenancy_db_name' => 'VALID-db-name456', 'tenancy_db_username' => 'valid_USERNAME123', + 'tenancy_db_password' => 'v a/1d_P@ssword!', ]); expect(fn () => $manager->createDatabase($validTenant))->not()->toThrow(InvalidArgumentException::class); + + if ($manager instanceof ManagesDatabaseUsers) { + expect(fn () => $manager->createUser($validTenant->database()))->not()->toThrow(InvalidArgumentException::class); + expect(fn () => $manager->deleteUser($validTenant->database()))->not()->toThrow(InvalidArgumentException::class); + } + expect(fn () => $manager->deleteDatabase($validTenant))->not()->toThrow(InvalidArgumentException::class); })->with('database_managers'); From 740d53e9cc1dd861c2e8584bd76ff28e050eef4f Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 29 Apr 2026 17:25:15 +0200 Subject: [PATCH 10/97] Rename ValidatesSqlParameters to ValidatesDatabaseParameters --- ...datesSqlParameters.php => ValidatesDatabaseParameters.php} | 4 +--- src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php | 4 ++-- src/Database/TenantDatabaseManagers/TenantDatabaseManager.php | 4 ++-- 3 files changed, 5 insertions(+), 7 deletions(-) rename src/Database/Concerns/{ValidatesSqlParameters.php => ValidatesDatabaseParameters.php} (91%) diff --git a/src/Database/Concerns/ValidatesSqlParameters.php b/src/Database/Concerns/ValidatesDatabaseParameters.php similarity index 91% rename from src/Database/Concerns/ValidatesSqlParameters.php rename to src/Database/Concerns/ValidatesDatabaseParameters.php index ad61e8f4..d42551d8 100644 --- a/src/Database/Concerns/ValidatesSqlParameters.php +++ b/src/Database/Concerns/ValidatesDatabaseParameters.php @@ -6,9 +6,7 @@ namespace Stancl\Tenancy\Database\Concerns; use InvalidArgumentException; -// todo@validation this trait's name might be a bit misleading -// it suggests validating parameters for SQL statements, but it is also used in SQLiteDatabaseManager to validate the database file name -trait ValidatesSqlParameters +trait ValidatesDatabaseParameters { /** * Characters allowed in the parameters. diff --git a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php index e4e6ab76..ab9325c9 100644 --- a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php @@ -7,14 +7,14 @@ namespace Stancl\Tenancy\Database\TenantDatabaseManagers; use Closure; use Illuminate\Database\Eloquent\Model; use PDO; -use Stancl\Tenancy\Database\Concerns\ValidatesSqlParameters; +use Stancl\Tenancy\Database\Concerns\ValidatesDatabaseParameters; use Stancl\Tenancy\Database\Contracts\TenantDatabaseManager; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; use Throwable; class SQLiteDatabaseManager implements TenantDatabaseManager { - use ValidatesSqlParameters; + use ValidatesDatabaseParameters; /** * SQLite database directory path. diff --git a/src/Database/TenantDatabaseManagers/TenantDatabaseManager.php b/src/Database/TenantDatabaseManagers/TenantDatabaseManager.php index 6c520e5e..a0822615 100644 --- a/src/Database/TenantDatabaseManagers/TenantDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/TenantDatabaseManager.php @@ -6,13 +6,13 @@ namespace Stancl\Tenancy\Database\TenantDatabaseManagers; use Illuminate\Database\Connection; use Illuminate\Support\Facades\DB; -use Stancl\Tenancy\Database\Concerns\ValidatesSqlParameters; +use Stancl\Tenancy\Database\Concerns\ValidatesDatabaseParameters; use Stancl\Tenancy\Database\Contracts\StatefulTenantDatabaseManager; use Stancl\Tenancy\Database\Exceptions\NoConnectionSetException; abstract class TenantDatabaseManager implements StatefulTenantDatabaseManager { - use ValidatesSqlParameters; + use ValidatesDatabaseParameters; /** The database connection to the server. */ protected string $connection; From 85929493d590d61d25a85b16ba41446564241bbf Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 29 Apr 2026 17:30:58 +0200 Subject: [PATCH 11/97] Improve ValidatesDatabaseParameters docblocks --- .../Concerns/ValidatesDatabaseParameters.php | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/Database/Concerns/ValidatesDatabaseParameters.php b/src/Database/Concerns/ValidatesDatabaseParameters.php index d42551d8..b10f05d4 100644 --- a/src/Database/Concerns/ValidatesDatabaseParameters.php +++ b/src/Database/Concerns/ValidatesDatabaseParameters.php @@ -6,6 +6,15 @@ namespace Stancl\Tenancy\Database\Concerns; use InvalidArgumentException; +/** + * Provides methods to validate database parameters (e.g. database names, usernames, passwords) + * before using them in SQL statements (or in file paths in the case of SQLiteDatabaseManager). + * + * Used where parameters can be provided by users, and where parameter binding isn't possible. + * + * @mixin \Stancl\Tenancy\Database\TenantDatabaseManagers\TenantDatabaseManager + * @mixin \Stancl\Tenancy\Database\TenantDatabaseManagers\SQLiteDatabaseManager + */ trait ValidatesDatabaseParameters { /** @@ -30,8 +39,9 @@ trait ValidatesDatabaseParameters } /** - * Validate that parameters (database names, usernames, etc.) - * only contain allowed characters before used in SQL statements. + * Ensure that parameters (database names, usernames, etc.) + * only contain allowed characters before used in SQL statements + * (or file names in the case of SQLiteDatabaseManager). * * By default, only the characters in static::parameterAllowlist() are allowed. * @@ -60,9 +70,9 @@ trait ValidatesDatabaseParameters } /** - * Validate that a password only contains allowed characters before used in SQL statements. + * Ensure password only contains allowed characters before used in SQL statements. * - * Used as a shorthand for validateParameter() with the less strict allowlist. + * Used as a shorthand for calling validateParameter() with the less strict allowlist. * * @throws InvalidArgumentException */ From f3f1ab977a149e28ca3e1561492e3dd334aa69af Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 30 Apr 2026 09:15:18 +0200 Subject: [PATCH 12/97] Skip null parameters in validateParameter Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/Database/Concerns/ValidatesDatabaseParameters.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Database/Concerns/ValidatesDatabaseParameters.php b/src/Database/Concerns/ValidatesDatabaseParameters.php index b10f05d4..12f98d7e 100644 --- a/src/Database/Concerns/ValidatesDatabaseParameters.php +++ b/src/Database/Concerns/ValidatesDatabaseParameters.php @@ -59,12 +59,20 @@ trait ValidatesDatabaseParameters $allowlist = $allowlist ?? static::parameterAllowlist(); foreach ((array) $parameters as $parameter) { + if (is_null($parameter)) { + continue; + } + foreach (str_split($parameter) as $char) { if (! str_contains($allowlist, $char)) { throw new InvalidArgumentException("Invalid character '{$char}' in parameter: {$parameter}"); } } } + throw new InvalidArgumentException("Invalid character '{$char}' in parameter: {$parameter}"); + } + } + } return $parameters; } From 75b74f2e6c7d942c83220bf20f2b5f2c989af38d Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 30 Apr 2026 09:28:48 +0200 Subject: [PATCH 13/97] Make validateParameter have void return type --- src/Database/Concerns/ManagesPostgresUsers.php | 4 +++- .../Concerns/ValidatesDatabaseParameters.php | 16 +++++----------- .../MicrosoftSQLDatabaseManager.php | 8 ++++++-- .../MySQLDatabaseManager.php | 4 +++- ...ntrolledMicrosoftSQLServerDatabaseManager.php | 8 ++++++-- .../PermissionControlledMySQLDatabaseManager.php | 4 +++- .../PostgreSQLDatabaseManager.php | 8 ++++++-- .../PostgreSQLSchemaManager.php | 8 ++++++-- 8 files changed, 38 insertions(+), 22 deletions(-) diff --git a/src/Database/Concerns/ManagesPostgresUsers.php b/src/Database/Concerns/ManagesPostgresUsers.php index ddc03a34..62b920b9 100644 --- a/src/Database/Concerns/ManagesPostgresUsers.php +++ b/src/Database/Concerns/ManagesPostgresUsers.php @@ -45,7 +45,9 @@ trait ManagesPostgresUsers public function deleteUser(DatabaseConfig $databaseConfig): bool { // Tenant DB username - $username = $this->validateParameter($databaseConfig->getUsername()); + $username = $databaseConfig->getUsername(); + + $this->validateParameter($username); // Tenant host connection config $connectionName = $this->connection()->getConfig('name'); diff --git a/src/Database/Concerns/ValidatesDatabaseParameters.php b/src/Database/Concerns/ValidatesDatabaseParameters.php index 12f98d7e..343bb9a5 100644 --- a/src/Database/Concerns/ValidatesDatabaseParameters.php +++ b/src/Database/Concerns/ValidatesDatabaseParameters.php @@ -47,13 +47,13 @@ trait ValidatesDatabaseParameters * * @throws InvalidArgumentException */ - protected function validateParameter(string|array|null $parameters, string|null $allowlist = null): string|array|null + protected function validateParameter(string|array|null $parameters, string|null $allowlist = null): void { if (is_null($parameters)) { - // Return null if there's nothing to validate + // Return early if there's nothing to validate // (e.g. when $databaseConfig->getUsername() of an // improperly created tenant is passed). - return null; + return; } $allowlist = $allowlist ?? static::parameterAllowlist(); @@ -69,12 +69,6 @@ trait ValidatesDatabaseParameters } } } - throw new InvalidArgumentException("Invalid character '{$char}' in parameter: {$parameter}"); - } - } - } - - return $parameters; } /** @@ -84,8 +78,8 @@ trait ValidatesDatabaseParameters * * @throws InvalidArgumentException */ - protected function validatePassword(string|null $password): string|null + protected function validatePassword(string|null $password): void { - return $this->validateParameter($password, static::passwordAllowlist()); + $this->validateParameter($password, static::passwordAllowlist()); } } diff --git a/src/Database/TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php index e40f9e6e..f28ffd1e 100644 --- a/src/Database/TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php @@ -10,14 +10,18 @@ class MicrosoftSQLDatabaseManager extends TenantDatabaseManager { public function createDatabase(TenantWithDatabase $tenant): bool { - $database = $this->validateParameter($tenant->database()->getName()); + $database = $tenant->database()->getName(); + + $this->validateParameter($database); return $this->connection()->statement("CREATE DATABASE [{$database}]"); } public function deleteDatabase(TenantWithDatabase $tenant): bool { - $database = $this->validateParameter($tenant->database()->getName()); + $database = $tenant->database()->getName(); + + $this->validateParameter($database); return $this->connection()->statement("DROP DATABASE [{$database}]"); } diff --git a/src/Database/TenantDatabaseManagers/MySQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/MySQLDatabaseManager.php index 605db7de..9747e9de 100644 --- a/src/Database/TenantDatabaseManagers/MySQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/MySQLDatabaseManager.php @@ -21,7 +21,9 @@ class MySQLDatabaseManager extends TenantDatabaseManager public function deleteDatabase(TenantWithDatabase $tenant): bool { - $database = $this->validateParameter($tenant->database()->getName()); + $database = $tenant->database()->getName(); + + $this->validateParameter($database); return $this->connection()->statement("DROP DATABASE `{$database}`"); } diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledMicrosoftSQLServerDatabaseManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledMicrosoftSQLServerDatabaseManager.php index 2671fcb5..cbc43d18 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledMicrosoftSQLServerDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledMicrosoftSQLServerDatabaseManager.php @@ -40,7 +40,9 @@ class PermissionControlledMicrosoftSQLServerDatabaseManager extends MicrosoftSQL public function deleteUser(DatabaseConfig $databaseConfig): bool { - $username = $this->validateParameter($databaseConfig->getUsername()); + $username = $databaseConfig->getUsername(); + + $this->validateParameter($username); return $this->connection()->statement("DROP LOGIN [{$username}]"); } @@ -59,7 +61,9 @@ class PermissionControlledMicrosoftSQLServerDatabaseManager extends MicrosoftSQL public function deleteDatabase(TenantWithDatabase $tenant): bool { - $name = $this->validateParameter($tenant->database()->getName()); + $name = $tenant->database()->getName(); + + $this->validateParameter($name); // Close all connections to the database before deleting it // Set the database to SINGLE_USER mode to ensure that diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php index d8eb3734..a4f74f8f 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php @@ -51,7 +51,9 @@ class PermissionControlledMySQLDatabaseManager extends MySQLDatabaseManager impl public function deleteUser(DatabaseConfig $databaseConfig): bool { - $username = $this->validateParameter($databaseConfig->getUsername()); + $username = $databaseConfig->getUsername(); + + $this->validateParameter($username); return $this->connection()->statement("DROP USER IF EXISTS '{$username}'"); } diff --git a/src/Database/TenantDatabaseManagers/PostgreSQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/PostgreSQLDatabaseManager.php index e1219e8c..fc293403 100644 --- a/src/Database/TenantDatabaseManagers/PostgreSQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/PostgreSQLDatabaseManager.php @@ -10,14 +10,18 @@ class PostgreSQLDatabaseManager extends TenantDatabaseManager { public function createDatabase(TenantWithDatabase $tenant): bool { - $name = $this->validateParameter($tenant->database()->getName()); + $name = $tenant->database()->getName(); + + $this->validateParameter($name); return $this->connection()->statement("CREATE DATABASE \"{$name}\" WITH TEMPLATE=template0"); } public function deleteDatabase(TenantWithDatabase $tenant): bool { - $name = $this->validateParameter($tenant->database()->getName()); + $name = $tenant->database()->getName(); + + $this->validateParameter($name); return $this->connection()->statement("DROP DATABASE \"{$name}\""); } diff --git a/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php b/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php index 3ffbc858..354eb768 100644 --- a/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php +++ b/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php @@ -10,14 +10,18 @@ class PostgreSQLSchemaManager extends TenantDatabaseManager { public function createDatabase(TenantWithDatabase $tenant): bool { - $name = $this->validateParameter($tenant->database()->getName()); + $name = $tenant->database()->getName(); + + $this->validateParameter($name); return $this->connection()->statement("CREATE SCHEMA \"{$name}\""); } public function deleteDatabase(TenantWithDatabase $tenant): bool { - $name = $this->validateParameter($tenant->database()->getName()); + $name = $tenant->database()->getName(); + + $this->validateParameter($name); return $this->connection()->statement("DROP SCHEMA \"{$name}\" CASCADE"); } From 322257f4569ad5741772496028f85630a39ac7ff Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 30 Apr 2026 09:49:03 +0200 Subject: [PATCH 14/97] Validate SQLite filename in databaseExists Add validation so that a malicious tenant DB name can't be used to detect if a file exists. --- .../TenantDatabaseManagers/SQLiteDatabaseManager.php | 8 +++++++- tests/TenantDatabaseManagerTest.php | 10 ++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php index ab9325c9..64fb603a 100644 --- a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php @@ -128,7 +128,13 @@ class SQLiteDatabaseManager implements TenantDatabaseManager public function databaseExists(string $name): bool { - return $this->isInMemory($name) || file_exists($this->getPath($name)); + if ($this->isInMemory($name)) { + return true; + } + + $this->validateParameter($name); + + return file_exists($this->getPath($name)); } public function makeConnectionConfig(array $baseConfig, string $databaseName): array diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index c4b514cb..2708c9b9 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -623,6 +623,16 @@ test('database managers validate parameters that cannot be bound', function ($dr expect(fn () => $manager->deleteDatabase($validTenant))->not()->toThrow(InvalidArgumentException::class); })->with('database_managers'); +test('sqlite database manager validates the name in databaseExists', function () { + $manager = app(SQLiteDatabaseManager::class); + + expect(fn () => $manager->databaseExists("../invalid-db-name.sqlite")) + ->toThrow(InvalidArgumentException::class); + + expect(fn () => $manager->databaseExists('valid-db_name.sqlite')) + ->not()->toThrow(InvalidArgumentException::class); +}); + // Datasets dataset('database_managers', [ ['mysql', MySQLDatabaseManager::class], From 46f73c42adc03227546c10cff84cd3406b34b515 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 30 Apr 2026 10:44:36 +0200 Subject: [PATCH 15/97] Improve ValidatesDatabaseParameters comments, delete extra early return --- .../Concerns/ValidatesDatabaseParameters.php | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/Database/Concerns/ValidatesDatabaseParameters.php b/src/Database/Concerns/ValidatesDatabaseParameters.php index 343bb9a5..adb4b1f5 100644 --- a/src/Database/Concerns/ValidatesDatabaseParameters.php +++ b/src/Database/Concerns/ValidatesDatabaseParameters.php @@ -10,7 +10,7 @@ use InvalidArgumentException; * Provides methods to validate database parameters (e.g. database names, usernames, passwords) * before using them in SQL statements (or in file paths in the case of SQLiteDatabaseManager). * - * Used where parameters can be provided by users, and where parameter binding isn't possible. + * Used where parameters can be provided by users, and where parameter binding cannot be used. * * @mixin \Stancl\Tenancy\Database\TenantDatabaseManagers\TenantDatabaseManager * @mixin \Stancl\Tenancy\Database\TenantDatabaseManagers\SQLiteDatabaseManager @@ -18,7 +18,10 @@ use InvalidArgumentException; trait ValidatesDatabaseParameters { /** - * Characters allowed in the parameters. + * Characters allowed in parameters. + * + * Used as the default allowlist for validateParameter(), which validates non-password + * parameters such as database names or usernames. */ protected static function parameterAllowlist(): string { @@ -45,21 +48,19 @@ trait ValidatesDatabaseParameters * * By default, only the characters in static::parameterAllowlist() are allowed. * + * Null parameters are skipped. + * * @throws InvalidArgumentException */ protected function validateParameter(string|array|null $parameters, string|null $allowlist = null): void { - if (is_null($parameters)) { - // Return early if there's nothing to validate - // (e.g. when $databaseConfig->getUsername() of an - // improperly created tenant is passed). - return; - } - $allowlist = $allowlist ?? static::parameterAllowlist(); foreach ((array) $parameters as $parameter) { if (is_null($parameter)) { + // Skip if there's nothing to validate + // (e.g. when $tenant->database()->getUsername() of an + // improperly created tenant is passed). continue; } @@ -74,7 +75,8 @@ trait ValidatesDatabaseParameters /** * Ensure password only contains allowed characters before used in SQL statements. * - * Used as a shorthand for calling validateParameter() with the less strict allowlist. + * Used as a shorthand for calling validateParameter() with the less strict allowlist + * to validate database user passwords. * * @throws InvalidArgumentException */ From 4bdb877ca476b482f2b3297b5f9a10da3eee742c Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 30 Apr 2026 10:45:29 +0200 Subject: [PATCH 16/97] Cover null parameter skipping Also cover that in-memory db names aren't validated in databaseExists --- tests/TenantDatabaseManagerTest.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index 2708c9b9..547b73d8 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -605,6 +605,16 @@ test('database managers validate parameters that cannot be bound', function ($dr expect(fn () => $manager->createUser($tenantWithInvalidPassword->database())) ->toThrow(InvalidArgumentException::class, $invalidPassword); + + // validateParameter() doesn't throw if a parameter is null + $tenantWithNullDbParameters = Tenant::make([ + 'tenancy_db_name' => null, + 'tenancy_db_username' => null, + 'tenancy_db_password' => null, + ]); + + expect(fn () => $manager->createUser($tenantWithNullDbParameters->database())) + ->not()->toThrow(InvalidArgumentException::class); } $validTenant = Tenant::make([ @@ -631,6 +641,10 @@ test('sqlite database manager validates the name in databaseExists', function () expect(fn () => $manager->databaseExists('valid-db_name.sqlite')) ->not()->toThrow(InvalidArgumentException::class); + + // In-memory database names aren't validated + expect(fn () => $manager->databaseExists('../_tenancy_inmemory_')) + ->not()->toThrow(InvalidArgumentException::class); }); // Datasets From 50ea524ad221c6ae73d93fda5408f47d87008780 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 30 Apr 2026 11:16:39 +0200 Subject: [PATCH 17/97] Simplify test, improve comments --- tests/TenantDatabaseManagerTest.php | 44 +++++++++-------------------- 1 file changed, 13 insertions(+), 31 deletions(-) diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index 547b73d8..a55b593a 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -551,14 +551,15 @@ test('database managers validate parameters that cannot be bound', function ($dr $manager->setConnection($driver); } + $invalidDatabaseName = "\"database_with_quotes\""; + if (! $manager instanceof ManagesDatabaseUsers) { - // Test that the createDatabase() and deleteDatabase() methods validate the database name. - // Only test non-permission controlled managers here since permission controlled managers - // override these methods to e.g. delete users before calling parent::deleteDatabase(), - // and with invalid DB name, the user deletion will already fail before we even get to actual - // deleteDatabase() logic. + // Only test createDatabase() and deleteDatabase() with non-permission controlled managers here + // since permission controlled managers override these methods to e.g. delete users before + // calling parent::deleteDatabase(), and with invalid DB name, the user deletion will already + // fail before we even get to actual deleteDatabase() logic. $tenant = Tenant::make([ - 'tenancy_db_name' => $invalidDatabaseName = "\"database_with_quotes\"", + 'tenancy_db_name' => $invalidDatabaseName, 'tenancy_db_username' => 'valid-username', ]); @@ -567,9 +568,7 @@ test('database managers validate parameters that cannot be bound', function ($dr expect(fn () => $manager->deleteDatabase($tenant)) ->toThrow(InvalidArgumentException::class, $invalidDatabaseName); - } - - if ($manager instanceof ManagesDatabaseUsers) { + } else { // Invalid username, createUser() and deleteUser() should // throw an invalid argument exception. $tenantWithInvalidUsername = Tenant::make([ @@ -584,13 +583,10 @@ test('database managers validate parameters that cannot be bound', function ($dr ->toThrow(InvalidArgumentException::class, $invalidUsername); // Invalid database name, createUser() should throw - // an invalid argument exception. - // - // grantPermissions() called in createUser() also validates DB and user names, - // but with the current implementation, if these parameters are - // invalid in createUser(), grantPermissions() will never be reached. + // an invalid argument exception. deleteUser() doesn't + // validate the DB name (it only validates the username). $tenantWithInvalidDatabase = Tenant::make([ - 'tenancy_db_name' => $invalidDatabaseName = 'db/with/slashes', + 'tenancy_db_name' => $invalidDatabaseName, 'tenancy_db_username' => 'valid_USERNAME', ]); @@ -606,31 +602,17 @@ test('database managers validate parameters that cannot be bound', function ($dr expect(fn () => $manager->createUser($tenantWithInvalidPassword->database())) ->toThrow(InvalidArgumentException::class, $invalidPassword); - // validateParameter() doesn't throw if a parameter is null $tenantWithNullDbParameters = Tenant::make([ 'tenancy_db_name' => null, 'tenancy_db_username' => null, 'tenancy_db_password' => null, ]); + // validateParameter() doesn't throw InvalidArgumentException if a parameter is null + // (an exception will be thrown, but not by validateParameter()). expect(fn () => $manager->createUser($tenantWithNullDbParameters->database())) ->not()->toThrow(InvalidArgumentException::class); } - - $validTenant = Tenant::make([ - 'tenancy_db_name' => 'VALID-db-name456', - 'tenancy_db_username' => 'valid_USERNAME123', - 'tenancy_db_password' => 'v a/1d_P@ssword!', - ]); - - expect(fn () => $manager->createDatabase($validTenant))->not()->toThrow(InvalidArgumentException::class); - - if ($manager instanceof ManagesDatabaseUsers) { - expect(fn () => $manager->createUser($validTenant->database()))->not()->toThrow(InvalidArgumentException::class); - expect(fn () => $manager->deleteUser($validTenant->database()))->not()->toThrow(InvalidArgumentException::class); - } - - expect(fn () => $manager->deleteDatabase($validTenant))->not()->toThrow(InvalidArgumentException::class); })->with('database_managers'); test('sqlite database manager validates the name in databaseExists', function () { From bacbf934e1a0688aa29d01e3bbc5cbdfaa6266d6 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 30 Apr 2026 14:52:53 +0200 Subject: [PATCH 18/97] Improve validation exception message --- .../Concerns/ValidatesDatabaseParameters.php | 2 +- tests/TenantDatabaseManagerTest.php | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Database/Concerns/ValidatesDatabaseParameters.php b/src/Database/Concerns/ValidatesDatabaseParameters.php index adb4b1f5..89a545cf 100644 --- a/src/Database/Concerns/ValidatesDatabaseParameters.php +++ b/src/Database/Concerns/ValidatesDatabaseParameters.php @@ -66,7 +66,7 @@ trait ValidatesDatabaseParameters foreach (str_split($parameter) as $char) { if (! str_contains($allowlist, $char)) { - throw new InvalidArgumentException("Invalid character '{$char}' in parameter: {$parameter}"); + throw new InvalidArgumentException("Forbidden character '{$char}' in database parameter."); } } } diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index a55b593a..b5c7f7d7 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -564,23 +564,23 @@ test('database managers validate parameters that cannot be bound', function ($dr ]); expect(fn () => $manager->createDatabase($tenant)) - ->toThrow(InvalidArgumentException::class, $invalidDatabaseName); + ->toThrow(InvalidArgumentException::class); expect(fn () => $manager->deleteDatabase($tenant)) - ->toThrow(InvalidArgumentException::class, $invalidDatabaseName); + ->toThrow(InvalidArgumentException::class); } else { // Invalid username, createUser() and deleteUser() should // throw an invalid argument exception. $tenantWithInvalidUsername = Tenant::make([ 'tenancy_db_name' => 'valid_database_name890', - 'tenancy_db_username' => $invalidUsername = "username with spaces", + 'tenancy_db_username' => "username with spaces", ]); expect(fn () => $manager->createUser($tenantWithInvalidUsername->database())) - ->toThrow(InvalidArgumentException::class, $invalidUsername); + ->toThrow(InvalidArgumentException::class, 'Forbidden character'); expect(fn () => $manager->deleteUser($tenantWithInvalidUsername->database())) - ->toThrow(InvalidArgumentException::class, $invalidUsername); + ->toThrow(InvalidArgumentException::class, 'Forbidden character'); // Invalid database name, createUser() should throw // an invalid argument exception. deleteUser() doesn't @@ -591,16 +591,16 @@ test('database managers validate parameters that cannot be bound', function ($dr ]); expect(fn () => $manager->createUser($tenantWithInvalidDatabase->database())) - ->toThrow(InvalidArgumentException::class, $invalidDatabaseName); + ->toThrow(InvalidArgumentException::class, 'Forbidden character'); $tenantWithInvalidPassword = Tenant::make([ 'tenancy_db_name' => 'valid_database_name890', 'tenancy_db_username' => 'valid_USERNAME', - 'tenancy_db_password' => $invalidPassword = "p'ssword", + 'tenancy_db_password' => "p'ssword", ]); expect(fn () => $manager->createUser($tenantWithInvalidPassword->database())) - ->toThrow(InvalidArgumentException::class, $invalidPassword); + ->toThrow(InvalidArgumentException::class, 'Forbidden character'); $tenantWithNullDbParameters = Tenant::make([ 'tenancy_db_name' => null, From 37a4c7dd276fd335975c22b10fb4b322ff70658b Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 30 Apr 2026 15:08:46 +0200 Subject: [PATCH 19/97] Check if paremeter is string --- src/Database/Concerns/ValidatesDatabaseParameters.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Database/Concerns/ValidatesDatabaseParameters.php b/src/Database/Concerns/ValidatesDatabaseParameters.php index 89a545cf..360f12a6 100644 --- a/src/Database/Concerns/ValidatesDatabaseParameters.php +++ b/src/Database/Concerns/ValidatesDatabaseParameters.php @@ -57,10 +57,10 @@ trait ValidatesDatabaseParameters $allowlist = $allowlist ?? static::parameterAllowlist(); foreach ((array) $parameters as $parameter) { - if (is_null($parameter)) { + if (! is_string($parameter)) { // Skip if there's nothing to validate // (e.g. when $tenant->database()->getUsername() of an - // improperly created tenant is passed). + // improperly created tenant is null and it gets passed). continue; } From 2bd3a868ecdd7656ddb7b48da290ec6067cee840 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 30 Apr 2026 16:14:06 +0200 Subject: [PATCH 20/97] Quote database parameter in GRANT statement for consistency The database name is always quoted in statements (without binding) now. --- .../PermissionControlledPostgreSQLSchemaManager.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php index 9fc66c6f..057726b0 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php @@ -25,7 +25,7 @@ class PermissionControlledPostgreSQLSchemaManager extends PostgreSQLSchemaManage $this->validateParameter([$username, $schema, $database]); - $this->connection()->statement("GRANT CONNECT ON DATABASE {$database} TO \"{$username}\""); + $this->connection()->statement("GRANT CONNECT ON DATABASE \"{$database}\" TO \"{$username}\""); $this->connection()->statement("GRANT USAGE, CREATE ON SCHEMA \"{$schema}\" TO \"{$username}\""); $this->connection()->statement("GRANT USAGE ON ALL SEQUENCES IN SCHEMA \"{$schema}\" TO \"{$username}\""); From 76c324d758539e1d55f25a8b2ac143d740b8138d Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 1 May 2026 09:03:50 +0200 Subject: [PATCH 21/97] Add `validateFilename()` Use validateFilename instead of validateParameter in SQLiteDatabaseManager. Directories are no longer considered valid SQLite database names. --- .../Concerns/ValidatesDatabaseParameters.php | 32 +++++++++++++++++-- .../SQLiteDatabaseManager.php | 13 ++------ tests/TenantDatabaseManagerTest.php | 10 +++--- 3 files changed, 37 insertions(+), 18 deletions(-) diff --git a/src/Database/Concerns/ValidatesDatabaseParameters.php b/src/Database/Concerns/ValidatesDatabaseParameters.php index 360f12a6..b261cb4b 100644 --- a/src/Database/Concerns/ValidatesDatabaseParameters.php +++ b/src/Database/Concerns/ValidatesDatabaseParameters.php @@ -28,6 +28,16 @@ trait ValidatesDatabaseParameters return 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-'; } + /** + * Characters allowed in filenames (SQLite databases). + * + * Allows dots to support file extensions (e.g. '.sqlite'). + */ + protected static function filenameAllowlist(): string + { + return 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.'; + } + /** * Characters allowed in database user passwords. * @@ -66,7 +76,7 @@ trait ValidatesDatabaseParameters foreach (str_split($parameter) as $char) { if (! str_contains($allowlist, $char)) { - throw new InvalidArgumentException("Forbidden character '{$char}' in database parameter."); + throw new InvalidArgumentException("Forbidden character '{$char}' in parameter."); } } } @@ -75,8 +85,8 @@ trait ValidatesDatabaseParameters /** * Ensure password only contains allowed characters before used in SQL statements. * - * Used as a shorthand for calling validateParameter() with the less strict allowlist - * to validate database user passwords. + * Used in permission controlled managers as a shorthand for calling validateParameter() + * with the less strict allowlist to validate database user passwords. * * @throws InvalidArgumentException */ @@ -84,4 +94,20 @@ trait ValidatesDatabaseParameters { $this->validateParameter($password, static::passwordAllowlist()); } + + /** + * Ensure filename only contains allowed characters and is not a directory name + * before used in file paths (e.g. SQLite databases). + * + * @throws InvalidArgumentException + * @see Stancl\Tenancy\Database\TenantDatabaseManagers\SQLiteDatabaseManager + */ + protected function validateFilename(string|null $filename): void + { + if (is_dir($filename)) { + throw new InvalidArgumentException("Filename '{$filename}' is a directory."); + } + + $this->validateParameter($filename, static::filenameAllowlist()); + } } diff --git a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php index 64fb603a..c2c55d87 100644 --- a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php @@ -60,11 +60,6 @@ class SQLiteDatabaseManager implements TenantDatabaseManager */ public static Closure|null $closeInMemoryConnectionUsing = null; - protected static function parameterAllowlist(): string - { - return 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.'; - } - public function createDatabase(TenantWithDatabase $tenant): bool { /** @var TenantWithDatabase&Model $tenant */ @@ -92,8 +87,6 @@ class SQLiteDatabaseManager implements TenantDatabaseManager return true; } - $this->validateParameter($name); - return file_put_contents($this->getPath($name), '') !== false; } @@ -109,8 +102,6 @@ class SQLiteDatabaseManager implements TenantDatabaseManager return true; } - $this->validateParameter($name); - $path = $this->getPath($name); try { @@ -132,8 +123,6 @@ class SQLiteDatabaseManager implements TenantDatabaseManager return true; } - $this->validateParameter($name); - return file_exists($this->getPath($name)); } @@ -159,6 +148,8 @@ class SQLiteDatabaseManager implements TenantDatabaseManager return rtrim(static::$path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $name; } + $this->validateFilename($name); + return database_path($name); } diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index b5c7f7d7..f28ad8c4 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -615,15 +615,17 @@ test('database managers validate parameters that cannot be bound', function ($dr } })->with('database_managers'); -test('sqlite database manager validates the name in databaseExists', function () { +test('sqlite database manager validates database filenames', function () { $manager = app(SQLiteDatabaseManager::class); - expect(fn () => $manager->databaseExists("../invalid-db-name.sqlite")) - ->toThrow(InvalidArgumentException::class); - + // Dots are allowed in database names expect(fn () => $manager->databaseExists('valid-db_name.sqlite')) ->not()->toThrow(InvalidArgumentException::class); + // Directories are not allowed as database names + expect(fn () => $manager->databaseExists("..")) + ->toThrow(InvalidArgumentException::class); + // In-memory database names aren't validated expect(fn () => $manager->databaseExists('../_tenancy_inmemory_')) ->not()->toThrow(InvalidArgumentException::class); From d3607f84bf16000dd8b2d911469a2a32c20735ed Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 1 May 2026 09:11:55 +0200 Subject: [PATCH 22/97] Use 'allowedCharacters' instead of 'allowlist', code quality --- .../Concerns/ValidatesDatabaseParameters.php | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/src/Database/Concerns/ValidatesDatabaseParameters.php b/src/Database/Concerns/ValidatesDatabaseParameters.php index b261cb4b..a39e8b1b 100644 --- a/src/Database/Concerns/ValidatesDatabaseParameters.php +++ b/src/Database/Concerns/ValidatesDatabaseParameters.php @@ -23,7 +23,7 @@ trait ValidatesDatabaseParameters * Used as the default allowlist for validateParameter(), which validates non-password * parameters such as database names or usernames. */ - protected static function parameterAllowlist(): string + protected static function allowedParameterCharacters(): string { return 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-'; } @@ -31,9 +31,9 @@ trait ValidatesDatabaseParameters /** * Characters allowed in filenames (SQLite databases). * - * Allows dots to support file extensions (e.g. '.sqlite'). + * Includes dots to support file extensions (e.g. '.sqlite'). */ - protected static function filenameAllowlist(): string + protected static function allowedFilenameCharacters(): string { return 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.'; } @@ -46,7 +46,7 @@ trait ValidatesDatabaseParameters * characters that can break out of the quoted SQL strings (so e.g. * ', ", \, and ` aren't allowed). */ - protected static function passwordAllowlist(): string + protected static function allowedPasswordCharacters(): string { return ' !#$%&()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]^_abcdefghijklmnopqrstuvwxyz{|}~'; } @@ -56,15 +56,15 @@ trait ValidatesDatabaseParameters * only contain allowed characters before used in SQL statements * (or file names in the case of SQLiteDatabaseManager). * - * By default, only the characters in static::parameterAllowlist() are allowed. + * By default, only the characters in static::allowedParameterCharacters() are allowed. * * Null parameters are skipped. * * @throws InvalidArgumentException */ - protected function validateParameter(string|array|null $parameters, string|null $allowlist = null): void + protected function validateParameter(string|array|null $parameters, string|null $allowedCharacters = null): void { - $allowlist = $allowlist ?? static::parameterAllowlist(); + $allowedCharacters ??= static::allowedParameterCharacters(); foreach ((array) $parameters as $parameter) { if (! is_string($parameter)) { @@ -74,16 +74,17 @@ trait ValidatesDatabaseParameters continue; } - foreach (str_split($parameter) as $char) { - if (! str_contains($allowlist, $char)) { - throw new InvalidArgumentException("Forbidden character '{$char}' in parameter."); + foreach (str_split($parameter) as $character) { + if (! str_contains($allowedCharacters, $character)) { + throw new InvalidArgumentException("Forbidden character '{$character}' in parameter."); } } } } /** - * Ensure password only contains allowed characters before used in SQL statements. + * Ensure password only contains allowed characters (static::allowedPasswordCharacters()) + * before used in SQL statements. * * Used in permission controlled managers as a shorthand for calling validateParameter() * with the less strict allowlist to validate database user passwords. @@ -92,12 +93,12 @@ trait ValidatesDatabaseParameters */ protected function validatePassword(string|null $password): void { - $this->validateParameter($password, static::passwordAllowlist()); + $this->validateParameter($password, static::allowedPasswordCharacters()); } /** - * Ensure filename only contains allowed characters and is not a directory name - * before used in file paths (e.g. SQLite databases). + * Ensure filename only contains allowed characters (static::allowedFilenameCharacters()) + * and is not a directory name before used in file paths (e.g. SQLite database names). * * @throws InvalidArgumentException * @see Stancl\Tenancy\Database\TenantDatabaseManagers\SQLiteDatabaseManager @@ -108,6 +109,6 @@ trait ValidatesDatabaseParameters throw new InvalidArgumentException("Filename '{$filename}' is a directory."); } - $this->validateParameter($filename, static::filenameAllowlist()); + $this->validateParameter($filename, static::allowedFilenameCharacters()); } } From e8168eb0b906c8ff9a8edbe274d59cb222eab4c4 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 1 May 2026 09:16:17 +0200 Subject: [PATCH 23/97] Add string check to validateFilename, swap validation order Validate characters first, only then throw if the filename is a directory. --- src/Database/Concerns/ValidatesDatabaseParameters.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Database/Concerns/ValidatesDatabaseParameters.php b/src/Database/Concerns/ValidatesDatabaseParameters.php index a39e8b1b..90807e4b 100644 --- a/src/Database/Concerns/ValidatesDatabaseParameters.php +++ b/src/Database/Concerns/ValidatesDatabaseParameters.php @@ -105,10 +105,10 @@ trait ValidatesDatabaseParameters */ protected function validateFilename(string|null $filename): void { - if (is_dir($filename)) { + $this->validateParameter($filename, static::allowedFilenameCharacters()); + + if (is_string($filename) && is_dir($filename)) { throw new InvalidArgumentException("Filename '{$filename}' is a directory."); } - - $this->validateParameter($filename, static::allowedFilenameCharacters()); } } From 9611a05f35beb9e08aede5f1e16bd935c4faee34 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 1 May 2026 09:34:11 +0200 Subject: [PATCH 24/97] Skip null parameters, throw for other non-string parameters --- src/Database/Concerns/ValidatesDatabaseParameters.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Database/Concerns/ValidatesDatabaseParameters.php b/src/Database/Concerns/ValidatesDatabaseParameters.php index 90807e4b..26580fa5 100644 --- a/src/Database/Concerns/ValidatesDatabaseParameters.php +++ b/src/Database/Concerns/ValidatesDatabaseParameters.php @@ -67,13 +67,17 @@ trait ValidatesDatabaseParameters $allowedCharacters ??= static::allowedParameterCharacters(); foreach ((array) $parameters as $parameter) { - if (! is_string($parameter)) { + if (is_null($parameter)) { // Skip if there's nothing to validate // (e.g. when $tenant->database()->getUsername() of an // improperly created tenant is null and it gets passed). continue; } + if (! is_string($parameter)) { + throw new InvalidArgumentException("Parameter has to be a string."); + } + foreach (str_split($parameter) as $character) { if (! str_contains($allowedCharacters, $character)) { throw new InvalidArgumentException("Forbidden character '{$character}' in parameter."); From f3836cc623081545f12d427d4a5e8288bf55b1f5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 1 May 2026 07:34:32 +0000 Subject: [PATCH 25/97] Fix code style (php-cs-fixer) --- src/Database/Concerns/ValidatesDatabaseParameters.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/Concerns/ValidatesDatabaseParameters.php b/src/Database/Concerns/ValidatesDatabaseParameters.php index 26580fa5..1c50a97b 100644 --- a/src/Database/Concerns/ValidatesDatabaseParameters.php +++ b/src/Database/Concerns/ValidatesDatabaseParameters.php @@ -75,7 +75,7 @@ trait ValidatesDatabaseParameters } if (! is_string($parameter)) { - throw new InvalidArgumentException("Parameter has to be a string."); + throw new InvalidArgumentException('Parameter has to be a string.'); } foreach (str_split($parameter) as $character) { From 2bdda23a569036aea51cbfaf63d84004e7ab8f7f Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 1 May 2026 10:37:22 +0200 Subject: [PATCH 26/97] Disallow empty strings as filenames --- .../Concerns/ValidatesDatabaseParameters.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/Database/Concerns/ValidatesDatabaseParameters.php b/src/Database/Concerns/ValidatesDatabaseParameters.php index 1c50a97b..f9438b52 100644 --- a/src/Database/Concerns/ValidatesDatabaseParameters.php +++ b/src/Database/Concerns/ValidatesDatabaseParameters.php @@ -111,8 +111,16 @@ trait ValidatesDatabaseParameters { $this->validateParameter($filename, static::allowedFilenameCharacters()); - if (is_string($filename) && is_dir($filename)) { - throw new InvalidArgumentException("Filename '{$filename}' is a directory."); + if (! is_string($filename)) { + throw new InvalidArgumentException("Filename has to be a string."); + } + + if ($filename === '') { + throw new InvalidArgumentException("Filename cannot be empty."); + } + + if (is_dir($filename)) { + throw new InvalidArgumentException("Filename ('{$filename}') cannot be a directory."); } } } From 1a01164b873847098529576d92f15478c25fef6b Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 1 May 2026 10:46:37 +0200 Subject: [PATCH 27/97] Make validateFilename accept string instead of ?string --- src/Database/Concerns/ValidatesDatabaseParameters.php | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/Database/Concerns/ValidatesDatabaseParameters.php b/src/Database/Concerns/ValidatesDatabaseParameters.php index f9438b52..4972174b 100644 --- a/src/Database/Concerns/ValidatesDatabaseParameters.php +++ b/src/Database/Concerns/ValidatesDatabaseParameters.php @@ -107,14 +107,10 @@ trait ValidatesDatabaseParameters * @throws InvalidArgumentException * @see Stancl\Tenancy\Database\TenantDatabaseManagers\SQLiteDatabaseManager */ - protected function validateFilename(string|null $filename): void + protected function validateFilename(string $filename): void { $this->validateParameter($filename, static::allowedFilenameCharacters()); - if (! is_string($filename)) { - throw new InvalidArgumentException("Filename has to be a string."); - } - if ($filename === '') { throw new InvalidArgumentException("Filename cannot be empty."); } From 665404e7faa248b00f3751abc8bdaed9eeb30e78 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 1 May 2026 11:44:56 +0200 Subject: [PATCH 28/97] Add `DatabaseTenancyBootstrapper::$harden` Since It's possible to update tenant's db_name to the central DB or the DB of another tenant. Setting $harden to true prevents tenants from connecting to the wrong databases. --- .../DatabaseTenancyBootstrapper.php | 29 +++++++++ .../DatabaseTenancyBootstrapper.php | 61 ++++++++++++++++++- 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/src/Bootstrappers/DatabaseTenancyBootstrapper.php b/src/Bootstrappers/DatabaseTenancyBootstrapper.php index 7f0bce0a..427b79a5 100644 --- a/src/Bootstrappers/DatabaseTenancyBootstrapper.php +++ b/src/Bootstrappers/DatabaseTenancyBootstrapper.php @@ -5,14 +5,23 @@ declare(strict_types=1); namespace Stancl\Tenancy\Bootstrappers; use Exception; +use Illuminate\Support\Facades\Schema; +use RuntimeException; use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; use Stancl\Tenancy\Database\DatabaseManager; use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException; +use Illuminate\Database\Eloquent\Model; class DatabaseTenancyBootstrapper implements TenancyBootstrapper { + /** + * When true, throw an exception if a tenant gets connected to + * another tenant's database or to the central database. + */ + public static bool $harden = false; + /** @var DatabaseManager */ protected $database; @@ -41,10 +50,30 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper } $this->database->connectToTenant($tenant); + + if (static::$harden) $this->harden($tenant); } public function revert(): void { $this->database->reconnectToCentral(); } + + protected function harden(Tenant $tenant): void + { + /** @var TenantWithDatabase&Model $tenant */ + $dbName = $tenant->database()->getName(); + + // Check if the current database is unique (i.e. no other tenant uses this database) + if ($tenant::where($tenant->getTenantKeyName(), '!=', $tenant->getTenantKey()) + ->where('data->tenancy_db_name', $dbName) + ->exists()) { + throw new RuntimeException("Tenant cannot use a database of another tenant."); + } + + // Check if the current database doesn't have the tenants table (i.e. it's not the central database) + if (Schema::hasTable($tenant->getTable())) { + throw new RuntimeException('Tenant cannot use the central database.'); + } + } } diff --git a/tests/Bootstrappers/DatabaseTenancyBootstrapper.php b/tests/Bootstrappers/DatabaseTenancyBootstrapper.php index 14109500..ec480135 100644 --- a/tests/Bootstrappers/DatabaseTenancyBootstrapper.php +++ b/tests/Bootstrappers/DatabaseTenancyBootstrapper.php @@ -1,18 +1,76 @@ [DatabaseTenancyBootstrapper::class], + ]); + + DatabaseTenancyBootstrapper::$harden = true; + + Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { + return $event->tenant; + })->toListener()); + + $tenant = Tenant::create(); + + $tenant->update([ + 'tenancy_db_name' => 'main', // Central database name + ]); + + // Harden blocks initialization for tenants that use central database + expect(fn () => tenancy()->initialize($tenant))->toThrow(RuntimeException::class); +}); + +test('harden prevents tenants from using a database of another tenant', function () { + config([ + 'tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class], + ]); + + DatabaseTenancyBootstrapper::$harden = true; + + Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { + return $event->tenant; + })->toListener()); + + $tenant = Tenant::create(); + + Tenant::create([ + 'tenancy_db_name' => $tenantDbName = 'foo' . Str::random(8), + ]); + + $tenant->update([ + 'tenancy_db_name' => $tenantDbName, // Database of another tenant + ]); + + // Harden blocks initialization for tenants that use a database of another tenant + expect(fn () => tenancy()->initialize($tenant))->toThrow(RuntimeException::class); }); test('database tenancy bootstrapper throws an exception if DATABASE_URL is set', function (string|null $databaseUrl) { @@ -32,4 +90,3 @@ test('database tenancy bootstrapper throws an exception if DATABASE_URL is set', expect(true)->toBe(true); })->with(['abc.us-east-1.rds.amazonaws.com', null]); - From fbd1e025648ed39c71461838d03e801eaec45db7 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 1 May 2026 11:50:01 +0200 Subject: [PATCH 29/97] Correct DatabaseTenancyBootstrapper test filename DatabaseTenancyBootstrapper is ignored by ./t, it should be suffixed with 'Test'. --- ...enancyBootstrapper.php => DatabaseTenancyBootstrapperTest.php} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/Bootstrappers/{DatabaseTenancyBootstrapper.php => DatabaseTenancyBootstrapperTest.php} (100%) diff --git a/tests/Bootstrappers/DatabaseTenancyBootstrapper.php b/tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php similarity index 100% rename from tests/Bootstrappers/DatabaseTenancyBootstrapper.php rename to tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php From fc6a931a32269e4881b9c14e9ea8ec27c050c231 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 1 May 2026 09:50:30 +0000 Subject: [PATCH 30/97] Fix code style (php-cs-fixer) --- src/Bootstrappers/DatabaseTenancyBootstrapper.php | 4 ++-- src/Database/Concerns/ValidatesDatabaseParameters.php | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Bootstrappers/DatabaseTenancyBootstrapper.php b/src/Bootstrappers/DatabaseTenancyBootstrapper.php index 427b79a5..2389ec76 100644 --- a/src/Bootstrappers/DatabaseTenancyBootstrapper.php +++ b/src/Bootstrappers/DatabaseTenancyBootstrapper.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Stancl\Tenancy\Bootstrappers; use Exception; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Schema; use RuntimeException; use Stancl\Tenancy\Contracts\TenancyBootstrapper; @@ -12,7 +13,6 @@ use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; use Stancl\Tenancy\Database\DatabaseManager; use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException; -use Illuminate\Database\Eloquent\Model; class DatabaseTenancyBootstrapper implements TenancyBootstrapper { @@ -68,7 +68,7 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper if ($tenant::where($tenant->getTenantKeyName(), '!=', $tenant->getTenantKey()) ->where('data->tenancy_db_name', $dbName) ->exists()) { - throw new RuntimeException("Tenant cannot use a database of another tenant."); + throw new RuntimeException('Tenant cannot use a database of another tenant.'); } // Check if the current database doesn't have the tenants table (i.e. it's not the central database) diff --git a/src/Database/Concerns/ValidatesDatabaseParameters.php b/src/Database/Concerns/ValidatesDatabaseParameters.php index 4972174b..ba6a7cbf 100644 --- a/src/Database/Concerns/ValidatesDatabaseParameters.php +++ b/src/Database/Concerns/ValidatesDatabaseParameters.php @@ -112,7 +112,7 @@ trait ValidatesDatabaseParameters $this->validateParameter($filename, static::allowedFilenameCharacters()); if ($filename === '') { - throw new InvalidArgumentException("Filename cannot be empty."); + throw new InvalidArgumentException('Filename cannot be empty.'); } if (is_dir($filename)) { From f5f5f1d4aae35c7670548371628306f7f8777499 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 1 May 2026 11:52:56 +0200 Subject: [PATCH 31/97] Fix DB bootstrapper test "database tenancy bootstrapper throws an exception if DATABASE_URL is set" was failing with the null $databaseUrl because the tenant DB was never created. This test was ignored during test runs because the test file lacked the 'Test' suffix. --- tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php b/tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php index ec480135..95c815c9 100644 --- a/tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php @@ -82,6 +82,10 @@ test('database tenancy bootstrapper throws an exception if DATABASE_URL is set', config(['tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class]]); + Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { + return $event->tenant; + })->toListener()); + $tenant1 = Tenant::create(); pest()->artisan('tenants:migrate'); From 52f68573025efe401d0fdb6ec91890eefe245aa3 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 1 May 2026 12:08:02 +0200 Subject: [PATCH 32/97] If harden throws an exception, revert connection back to central --- src/Bootstrappers/DatabaseTenancyBootstrapper.php | 11 ++++++++++- .../Bootstrappers/DatabaseTenancyBootstrapperTest.php | 7 +++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/Bootstrappers/DatabaseTenancyBootstrapper.php b/src/Bootstrappers/DatabaseTenancyBootstrapper.php index 2389ec76..0065f07a 100644 --- a/src/Bootstrappers/DatabaseTenancyBootstrapper.php +++ b/src/Bootstrappers/DatabaseTenancyBootstrapper.php @@ -51,7 +51,16 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper $this->database->connectToTenant($tenant); - if (static::$harden) $this->harden($tenant); + try { + if (static::$harden) { + $this->harden($tenant); + } + } catch (RuntimeException $e) { + // Revert connection back to central + $this->revert(); + + throw $e; + } } public function revert(): void diff --git a/tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php b/tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php index 95c815c9..482d6e69 100644 --- a/tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php @@ -13,6 +13,7 @@ use Stancl\Tenancy\Tests\Etc\Tenant; use function Stancl\Tenancy\Tests\pest; use Illuminate\Support\Str; +use Illuminate\Support\Facades\DB; $cleanup = function () { DatabaseTenancyBootstrapper::$harden = false; @@ -46,6 +47,9 @@ test('harden prevents tenants from using the central database', function () { // Harden blocks initialization for tenants that use central database expect(fn () => tenancy()->initialize($tenant))->toThrow(RuntimeException::class); + + // Connection should be reverted back to central + expect(DB::connection()->getName())->toBe('central'); }); test('harden prevents tenants from using a database of another tenant', function () { @@ -71,6 +75,9 @@ test('harden prevents tenants from using a database of another tenant', function // Harden blocks initialization for tenants that use a database of another tenant expect(fn () => tenancy()->initialize($tenant))->toThrow(RuntimeException::class); + + // Connection should be reverted back to central + expect(DB::connection()->getName())->toBe('central'); }); test('database tenancy bootstrapper throws an exception if DATABASE_URL is set', function (string|null $databaseUrl) { From 0ce3d863cedcd2c8ebfa4f0ba3833da980c3e3e6 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 1 May 2026 12:11:00 +0200 Subject: [PATCH 33/97] DATABASE_URL test: set config for both datasets --- tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php b/tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php index 482d6e69..042590f1 100644 --- a/tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php @@ -81,9 +81,9 @@ test('harden prevents tenants from using a database of another tenant', function }); test('database tenancy bootstrapper throws an exception if DATABASE_URL is set', function (string|null $databaseUrl) { - if ($databaseUrl) { - config(['database.connections.central.url' => $databaseUrl]); + config(['database.connections.central.url' => $databaseUrl]); + if ($databaseUrl) { pest()->expectException(Exception::class); } From 2ae1f79d50bc3ad464dfe04db6f4ed8b946e2083 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 1 May 2026 12:32:03 +0200 Subject: [PATCH 34/97] Cover empty string parameters --- tests/TenantDatabaseManagerTest.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index f28ad8c4..dfceb48a 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -622,8 +622,12 @@ test('sqlite database manager validates database filenames', function () { expect(fn () => $manager->databaseExists('valid-db_name.sqlite')) ->not()->toThrow(InvalidArgumentException::class); - // Directories are not allowed as database names - expect(fn () => $manager->databaseExists("..")) + // Directory names are considered invalid input for database names + expect(fn () => $manager->databaseExists('..')) + ->toThrow(InvalidArgumentException::class); + + // Empty strings are considered invalid input for database names + expect(fn () => $manager->databaseExists('')) ->toThrow(InvalidArgumentException::class); // In-memory database names aren't validated From b1f0d0a43ccef78c5271f448c4e24a11b399058f Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 1 May 2026 12:34:28 +0200 Subject: [PATCH 35/97] Get central DB from config in harden test Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php b/tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php index 042590f1..3030b8ed 100644 --- a/tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php @@ -42,7 +42,7 @@ test('harden prevents tenants from using the central database', function () { $tenant = Tenant::create(); $tenant->update([ - 'tenancy_db_name' => 'main', // Central database name + 'tenancy_db_name' => config('database.connections.central.database'), // Central database name ]); // Harden blocks initialization for tenants that use central database From 7363318f6e198b04853f6b74200eb73b2bc66fa9 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 1 May 2026 13:09:37 +0200 Subject: [PATCH 36/97] Make in-memory DB detection more strict In-memory DBs have to start with "file:_tenancy_inmemory_". This prevents path traversal. --- .../TenantDatabaseManagers/SQLiteDatabaseManager.php | 2 +- tests/TenantDatabaseManagerTest.php | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php index c2c55d87..9df56ccb 100644 --- a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php @@ -155,6 +155,6 @@ class SQLiteDatabaseManager implements TenantDatabaseManager public static function isInMemory(string $name): bool { - return $name === ':memory:' || str_contains($name, '_tenancy_inmemory_'); + return $name === ':memory:' || str_starts_with($name, 'file:_tenancy_inmemory_'); } } diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index dfceb48a..0a51bd1d 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -615,7 +615,7 @@ test('database managers validate parameters that cannot be bound', function ($dr } })->with('database_managers'); -test('sqlite database manager validates database filenames', function () { +test('sqlite database manager validates database names', function () { $manager = app(SQLiteDatabaseManager::class); // Dots are allowed in database names @@ -630,9 +630,13 @@ test('sqlite database manager validates database filenames', function () { expect(fn () => $manager->databaseExists('')) ->toThrow(InvalidArgumentException::class); - // In-memory database names aren't validated - expect(fn () => $manager->databaseExists('../_tenancy_inmemory_')) + // In-memory database names have to start with 'file:_tenancy_inmemory_' + expect(fn () => $manager->databaseExists('file:_tenancy_inmemory_123?mode=memory&cache=shared')) ->not()->toThrow(InvalidArgumentException::class); + + // Doesn't start with 'file:_tenancy_inmemory_', not considered an in-memory database, filename validation applies + expect(fn () => $manager->databaseExists('../_tenancy_inmemory_')) + ->toThrow(InvalidArgumentException::class); }); // Datasets From 48b48379059c7dcc9f253c8b7aa33a757f751e82 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 1 May 2026 14:09:56 +0200 Subject: [PATCH 37/97] Validate in-memory db names, move SQLite-specific methods to the SQLiteManager --- .../Concerns/ValidatesDatabaseParameters.php | 36 ++------------ .../SQLiteDatabaseManager.php | 48 ++++++++++++++++++- 2 files changed, 49 insertions(+), 35 deletions(-) diff --git a/src/Database/Concerns/ValidatesDatabaseParameters.php b/src/Database/Concerns/ValidatesDatabaseParameters.php index ba6a7cbf..0a8e6d9d 100644 --- a/src/Database/Concerns/ValidatesDatabaseParameters.php +++ b/src/Database/Concerns/ValidatesDatabaseParameters.php @@ -28,16 +28,6 @@ trait ValidatesDatabaseParameters return 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-'; } - /** - * Characters allowed in filenames (SQLite databases). - * - * Includes dots to support file extensions (e.g. '.sqlite'). - */ - protected static function allowedFilenameCharacters(): string - { - return 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.'; - } - /** * Characters allowed in database user passwords. * @@ -62,7 +52,7 @@ trait ValidatesDatabaseParameters * * @throws InvalidArgumentException */ - protected function validateParameter(string|array|null $parameters, string|null $allowedCharacters = null): void + protected static function validateParameter(string|array|null $parameters, string|null $allowedCharacters = null): void { $allowedCharacters ??= static::allowedParameterCharacters(); @@ -95,28 +85,8 @@ trait ValidatesDatabaseParameters * * @throws InvalidArgumentException */ - protected function validatePassword(string|null $password): void + protected static function validatePassword(string|null $password): void { - $this->validateParameter($password, static::allowedPasswordCharacters()); - } - - /** - * Ensure filename only contains allowed characters (static::allowedFilenameCharacters()) - * and is not a directory name before used in file paths (e.g. SQLite database names). - * - * @throws InvalidArgumentException - * @see Stancl\Tenancy\Database\TenantDatabaseManagers\SQLiteDatabaseManager - */ - protected function validateFilename(string $filename): void - { - $this->validateParameter($filename, static::allowedFilenameCharacters()); - - if ($filename === '') { - throw new InvalidArgumentException('Filename cannot be empty.'); - } - - if (is_dir($filename)) { - throw new InvalidArgumentException("Filename ('{$filename}') cannot be a directory."); - } + static::validateParameter($password, allowedCharacters: static::allowedPasswordCharacters()); } } diff --git a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php index 9df56ccb..657d0c1b 100644 --- a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php @@ -11,6 +11,7 @@ use Stancl\Tenancy\Database\Concerns\ValidatesDatabaseParameters; use Stancl\Tenancy\Database\Contracts\TenantDatabaseManager; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; use Throwable; +use InvalidArgumentException; class SQLiteDatabaseManager implements TenantDatabaseManager { @@ -60,6 +61,16 @@ class SQLiteDatabaseManager implements TenantDatabaseManager */ public static Closure|null $closeInMemoryConnectionUsing = null; + /** + * Characters allowed in database names. + * + * Includes dots to support file extensions (e.g. '.sqlite'). + */ + protected static function allowedDatabaseNameCharacters(): string + { + return 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.'; + } + public function createDatabase(TenantWithDatabase $tenant): bool { /** @var TenantWithDatabase&Model $tenant */ @@ -136,6 +147,8 @@ class SQLiteDatabaseManager implements TenantDatabaseManager (static::$persistInMemoryConnectionUsing)(new PDO($dsn), $dsn); } } else { + $this->validateDatabaseName($databaseName); + $baseConfig['database'] = database_path($databaseName); } @@ -148,13 +161,44 @@ class SQLiteDatabaseManager implements TenantDatabaseManager return rtrim(static::$path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $name; } - $this->validateFilename($name); + $this->validateDatabaseName($name); return database_path($name); } public static function isInMemory(string $name): bool { - return $name === ':memory:' || str_starts_with($name, 'file:_tenancy_inmemory_'); + if ($name === ':memory:') { + return true; + } + + if (str_starts_with($name, 'file:_tenancy_inmemory_') && + str_ends_with($name, '?mode=memory&cache=shared')) { + // Named in-memory DBs are formatted like 'file:_tenancy_inmemory_tenant123?mode=memory&cache=shared' + static::validateDatabaseName($name, ':?=&'); + + return true; + } + + return false; + } + + /** + * Ensure database name only contains allowed characters + * (static::allowedDatabaseNameCharacters() + $extraAllowedCharacters) and is not a directory name. + * + * @throws InvalidArgumentException + */ + protected static function validateDatabaseName(string $name, string $extraAllowedCharacters = ''): void + { + static::validateParameter($name, static::allowedDatabaseNameCharacters() . $extraAllowedCharacters); + + if ($name === '') { + throw new InvalidArgumentException('Database name cannot be empty.'); + } + + if (is_dir($name)) { + throw new InvalidArgumentException("Database name cannot be a directory."); + } } } From 7683befa54d2080bd11dbe8dc85d129503ce9024 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 1 May 2026 12:10:13 +0000 Subject: [PATCH 38/97] Fix code style (php-cs-fixer) --- src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php index 657d0c1b..d988b08f 100644 --- a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php @@ -6,12 +6,12 @@ namespace Stancl\Tenancy\Database\TenantDatabaseManagers; use Closure; use Illuminate\Database\Eloquent\Model; +use InvalidArgumentException; use PDO; use Stancl\Tenancy\Database\Concerns\ValidatesDatabaseParameters; use Stancl\Tenancy\Database\Contracts\TenantDatabaseManager; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; use Throwable; -use InvalidArgumentException; class SQLiteDatabaseManager implements TenantDatabaseManager { @@ -198,7 +198,7 @@ class SQLiteDatabaseManager implements TenantDatabaseManager } if (is_dir($name)) { - throw new InvalidArgumentException("Database name cannot be a directory."); + throw new InvalidArgumentException('Database name cannot be a directory.'); } } } From e48d82277253a2172f5569fdb10a7764a2338396 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 1 May 2026 14:15:47 +0200 Subject: [PATCH 39/97] Validate SQLite DB name unconditionally in getPath() --- src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php index d988b08f..c93fb19c 100644 --- a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php @@ -157,12 +157,12 @@ class SQLiteDatabaseManager implements TenantDatabaseManager public function getPath(string $name): string { + $this->validateDatabaseName($name); + if (static::$path) { return rtrim(static::$path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $name; } - $this->validateDatabaseName($name); - return database_path($name); } From 9a9adc0d99ba911d4ac98b9f19581713d99c3809 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 1 May 2026 14:27:56 +0200 Subject: [PATCH 40/97] Use getPath() in makeConnectionConfig() makeConnectionConfig() would use database_path() to generate the DB path, which is correct only when the $path static property is null. --- src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php index c93fb19c..d0e16cd4 100644 --- a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php @@ -147,9 +147,7 @@ class SQLiteDatabaseManager implements TenantDatabaseManager (static::$persistInMemoryConnectionUsing)(new PDO($dsn), $dsn); } } else { - $this->validateDatabaseName($databaseName); - - $baseConfig['database'] = database_path($databaseName); + $baseConfig['database'] = $this->getPath($databaseName); } return $baseConfig; From 7f93f4460a956628df759937423172f49a59c196 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 1 May 2026 14:35:18 +0200 Subject: [PATCH 41/97] Test that the SQLite DB manager recognizes in-memory DBs --- tests/TenantDatabaseManagerTest.php | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index 0a51bd1d..caa9109e 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -615,7 +615,7 @@ test('database managers validate parameters that cannot be bound', function ($dr } })->with('database_managers'); -test('sqlite database manager validates database names', function () { +test('sqlite database manager validates database names correctly', function () { $manager = app(SQLiteDatabaseManager::class); // Dots are allowed in database names @@ -629,14 +629,19 @@ test('sqlite database manager validates database names', function () { // Empty strings are considered invalid input for database names expect(fn () => $manager->databaseExists('')) ->toThrow(InvalidArgumentException::class); +}); - // In-memory database names have to start with 'file:_tenancy_inmemory_' - expect(fn () => $manager->databaseExists('file:_tenancy_inmemory_123?mode=memory&cache=shared')) - ->not()->toThrow(InvalidArgumentException::class); +test('sqlite database manager recognizes inmemory databases correctly', function () { + $manager = app(SQLiteDatabaseManager::class); - // Doesn't start with 'file:_tenancy_inmemory_', not considered an in-memory database, filename validation applies - expect(fn () => $manager->databaseExists('../_tenancy_inmemory_')) - ->toThrow(InvalidArgumentException::class); + expect($manager->isInMemory('file:_tenancy_inmemory_123?mode=memory&cache=shared'))->toBeTrue(); + expect($manager->isInMemory(':memory:'))->toBeTrue(); + + // Missing the '?mode=memory&cache=shared' suffix + expect($manager->isInMemory('file:_tenancy_inmemory_456'))->toBeFalse(); + + // Doesn't start with 'file:_tenancy_inmemory_' + expect($manager->isInMemory('_tenancy_inmemory_123?mode=memory&cache=shared'))->toBeFalse(); }); // Datasets From 7660ddd3ab5a0d00d1f902820e8611afe0fe64ce Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 1 May 2026 14:43:27 +0200 Subject: [PATCH 42/97] Improve readability of harden() call --- src/Bootstrappers/DatabaseTenancyBootstrapper.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Bootstrappers/DatabaseTenancyBootstrapper.php b/src/Bootstrappers/DatabaseTenancyBootstrapper.php index 0065f07a..30a55f73 100644 --- a/src/Bootstrappers/DatabaseTenancyBootstrapper.php +++ b/src/Bootstrappers/DatabaseTenancyBootstrapper.php @@ -51,15 +51,15 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper $this->database->connectToTenant($tenant); - try { - if (static::$harden) { + if (static::$harden) { + try { $this->harden($tenant); - } - } catch (RuntimeException $e) { - // Revert connection back to central - $this->revert(); + } catch (RuntimeException $e) { + // Revert connection back to central + $this->revert(); - throw $e; + throw $e; + } } } From 26c161a9407e1b71e3bea5ca4d63ed008591d5eb Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 1 May 2026 15:16:54 +0200 Subject: [PATCH 43/97] Add regression test for makeConnectionConfig not working correctly with custom $path In makeConnectionConfig, changing the $this->getPath($databaseName) line back to `$baseConfig['database'] = database_path($databaseName);` will make the added test fail. --- tests/TenantDatabaseManagerTest.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index caa9109e..f29f9a63 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -644,6 +644,23 @@ test('sqlite database manager recognizes inmemory databases correctly', function expect($manager->isInMemory('_tenancy_inmemory_123?mode=memory&cache=shared'))->toBeFalse(); }); +test('sqlite database manager respects the configured path while making the database config', function () { + config()->set([ + 'tenancy.database.template_tenant_connection' => 'sqlite', + ]); + + $tenant = Tenant::make([ + 'tenancy_db_name' => 'tenant.sqlite', + ]); + + // SQLiteDatabaseManager::$path is null, the database path is built using database_path() + expect($tenant->database()->connection()['database'])->toBe(database_path('tenant.sqlite')); + + SQLiteDatabaseManager::$path = $customPath = '/custom/path/'; + + expect($tenant->database()->connection()['database'])->toBe($customPath . 'tenant.sqlite'); +}); + // Datasets dataset('database_managers', [ ['mysql', MySQLDatabaseManager::class], From 429e0985fd284b754c72a37dc50253f2764e6249 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 1 May 2026 15:17:38 +0200 Subject: [PATCH 44/97] Improve code quality and comments --- src/Database/Concerns/ValidatesDatabaseParameters.php | 5 +++-- .../TenantDatabaseManagers/SQLiteDatabaseManager.php | 6 +----- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/Database/Concerns/ValidatesDatabaseParameters.php b/src/Database/Concerns/ValidatesDatabaseParameters.php index 0a8e6d9d..0b1ce746 100644 --- a/src/Database/Concerns/ValidatesDatabaseParameters.php +++ b/src/Database/Concerns/ValidatesDatabaseParameters.php @@ -20,7 +20,7 @@ trait ValidatesDatabaseParameters /** * Characters allowed in parameters. * - * Used as the default allowlist for validateParameter(), which validates non-password + * Used as the default allowlist in validateParameter(), which validates non-password * parameters such as database names or usernames. */ protected static function allowedParameterCharacters(): string @@ -44,7 +44,7 @@ trait ValidatesDatabaseParameters /** * Ensure that parameters (database names, usernames, etc.) * only contain allowed characters before used in SQL statements - * (or file names in the case of SQLiteDatabaseManager). + * (or paths in the case of SQLiteDatabaseManager). * * By default, only the characters in static::allowedParameterCharacters() are allowed. * @@ -65,6 +65,7 @@ trait ValidatesDatabaseParameters } if (! is_string($parameter)) { + // E.g. if a parameter is retrieved from the config, it isn't necessarily a string throw new InvalidArgumentException('Parameter has to be a string.'); } diff --git a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php index d0e16cd4..8f52a9ed 100644 --- a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php @@ -130,11 +130,7 @@ class SQLiteDatabaseManager implements TenantDatabaseManager public function databaseExists(string $name): bool { - if ($this->isInMemory($name)) { - return true; - } - - return file_exists($this->getPath($name)); + return $this->isInMemory($name) || file_exists($this->getPath($name)); } public function makeConnectionConfig(array $baseConfig, string $databaseName): array From ea20eb13b6cf3144c5272756283b04e537209b65 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 1 May 2026 15:22:40 +0200 Subject: [PATCH 45/97] Validate in-memory DBs outside of isInMemory isInMemory should check if the name looks ilke an in-memory database name and return bool (it shouldn't throw validation errors). Also, make the validation methods non-static. --- .../Concerns/ValidatesDatabaseParameters.php | 16 ++++++------- .../SQLiteDatabaseManager.php | 24 +++++++------------ 2 files changed, 17 insertions(+), 23 deletions(-) diff --git a/src/Database/Concerns/ValidatesDatabaseParameters.php b/src/Database/Concerns/ValidatesDatabaseParameters.php index 0b1ce746..18af74c7 100644 --- a/src/Database/Concerns/ValidatesDatabaseParameters.php +++ b/src/Database/Concerns/ValidatesDatabaseParameters.php @@ -23,7 +23,7 @@ trait ValidatesDatabaseParameters * Used as the default allowlist in validateParameter(), which validates non-password * parameters such as database names or usernames. */ - protected static function allowedParameterCharacters(): string + protected function allowedParameterCharacters(): string { return 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-'; } @@ -36,7 +36,7 @@ trait ValidatesDatabaseParameters * characters that can break out of the quoted SQL strings (so e.g. * ', ", \, and ` aren't allowed). */ - protected static function allowedPasswordCharacters(): string + protected function allowedPasswordCharacters(): string { return ' !#$%&()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]^_abcdefghijklmnopqrstuvwxyz{|}~'; } @@ -46,15 +46,15 @@ trait ValidatesDatabaseParameters * only contain allowed characters before used in SQL statements * (or paths in the case of SQLiteDatabaseManager). * - * By default, only the characters in static::allowedParameterCharacters() are allowed. + * By default, only the characters in allowedParameterCharacters() are allowed. * * Null parameters are skipped. * * @throws InvalidArgumentException */ - protected static function validateParameter(string|array|null $parameters, string|null $allowedCharacters = null): void + protected function validateParameter(string|array|null $parameters, string|null $allowedCharacters = null): void { - $allowedCharacters ??= static::allowedParameterCharacters(); + $allowedCharacters ??= $this->allowedParameterCharacters(); foreach ((array) $parameters as $parameter) { if (is_null($parameter)) { @@ -78,7 +78,7 @@ trait ValidatesDatabaseParameters } /** - * Ensure password only contains allowed characters (static::allowedPasswordCharacters()) + * Ensure password only contains allowed characters (allowedPasswordCharacters()) * before used in SQL statements. * * Used in permission controlled managers as a shorthand for calling validateParameter() @@ -86,8 +86,8 @@ trait ValidatesDatabaseParameters * * @throws InvalidArgumentException */ - protected static function validatePassword(string|null $password): void + protected function validatePassword(string|null $password): void { - static::validateParameter($password, allowedCharacters: static::allowedPasswordCharacters()); + $this->validateParameter($password, allowedCharacters: $this->allowedPasswordCharacters()); } } diff --git a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php index 8f52a9ed..129cdb37 100644 --- a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php @@ -136,6 +136,9 @@ class SQLiteDatabaseManager implements TenantDatabaseManager public function makeConnectionConfig(array $baseConfig, string $databaseName): array { if ($this->isInMemory($databaseName)) { + // Named in-memory DBs are formatted like 'file:_tenancy_inmemory_tenant123?mode=memory&cache=shared' + $this->validateDatabaseName($databaseName, ':?=&'); + $baseConfig['database'] = $databaseName; if (static::$persistInMemoryConnectionUsing !== null) { @@ -162,30 +165,21 @@ class SQLiteDatabaseManager implements TenantDatabaseManager public static function isInMemory(string $name): bool { - if ($name === ':memory:') { - return true; - } + $isNamed = str_starts_with($name, 'file:_tenancy_inmemory_') && + str_ends_with($name, '?mode=memory&cache=shared'); - if (str_starts_with($name, 'file:_tenancy_inmemory_') && - str_ends_with($name, '?mode=memory&cache=shared')) { - // Named in-memory DBs are formatted like 'file:_tenancy_inmemory_tenant123?mode=memory&cache=shared' - static::validateDatabaseName($name, ':?=&'); - - return true; - } - - return false; + return $name === ':memory:' || $isNamed; } /** * Ensure database name only contains allowed characters - * (static::allowedDatabaseNameCharacters() + $extraAllowedCharacters) and is not a directory name. + * (allowedDatabaseNameCharacters() + $extraAllowedCharacters) and is not a directory name. * * @throws InvalidArgumentException */ - protected static function validateDatabaseName(string $name, string $extraAllowedCharacters = ''): void + protected function validateDatabaseName(string $name, string $extraAllowedCharacters = ''): void { - static::validateParameter($name, static::allowedDatabaseNameCharacters() . $extraAllowedCharacters); + $this->validateParameter($name, $this->allowedDatabaseNameCharacters() . $extraAllowedCharacters); if ($name === '') { throw new InvalidArgumentException('Database name cannot be empty.'); From 405aaafb4e97dbb250983496fe64c938892da23b Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 4 May 2026 11:15:51 +0200 Subject: [PATCH 46/97] Handle MySQL charset and collation Make createDatabase execute CREATE DATABASE without passing charset and collation so that if these parameters are null, the MySQL server's defaults will be used. Only add charset and collation to the statement if they're not null. --- .../MySQLDatabaseManager.php | 16 ++++- tests/TenantDatabaseManagerTest.php | 70 +++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/src/Database/TenantDatabaseManagers/MySQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/MySQLDatabaseManager.php index 9747e9de..ff61a3e8 100644 --- a/src/Database/TenantDatabaseManagers/MySQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/MySQLDatabaseManager.php @@ -16,7 +16,21 @@ class MySQLDatabaseManager extends TenantDatabaseManager $this->validateParameter([$database, $charset, $collation]); - return $this->connection()->statement("CREATE DATABASE `{$database}` CHARACTER SET `$charset` COLLATE `$collation`"); + // MySQL defaults to the server's charset and collation + // if charset and collation are not specified. + // If charset is specified but collation is null, MySQL + // will choose a default collation for the specified charset (and vice versa). + $statement = "CREATE DATABASE `{$database}`"; + + if ($charset !== null) { + $statement .= " CHARACTER SET `{$charset}`"; + } + + if ($collation !== null) { + $statement .= " COLLATE `{$collation}`"; + } + + return $this->connection()->statement($statement); } public function deleteDatabase(TenantWithDatabase $tenant): bool diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index f29f9a63..cd5089a2 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -661,6 +661,76 @@ test('sqlite database manager respects the configured path while making the data expect($tenant->database()->connection()['database'])->toBe($customPath . 'tenant.sqlite'); }); +test('newly created tenant databases use the correct charset and collation with mysql', function () { + config([ + 'tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class], + ]); + + Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { + return $event->tenant; + })->toListener()); + + withBootstrapping(); + + $charset = fn () => DB::selectOne('SELECT DEFAULT_CHARACTER_SET_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = DATABASE()')->DEFAULT_CHARACTER_SET_NAME; + $collation = fn () => DB::selectOne('SELECT DEFAULT_COLLATION_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = DATABASE()')->DEFAULT_COLLATION_NAME; + + $defaultTenant = Tenant::create(); + + tenancy()->initialize($defaultTenant); + + // No charset or collation specified, + // defaults from the MySQL config used. + expect($charset())->toBe('utf8mb4'); + expect($collation())->toBe('utf8mb4_unicode_ci'); + + $tenantWithCharsetAndCollation = Tenant::create([ + 'tenancy_db_charset' => 'latin1', + 'tenancy_db_collation' => 'latin1_swedish_ci', + ]); + + tenancy()->initialize($tenantWithCharsetAndCollation); + + // Custom charset and collation from tenant config + expect($charset())->toBe('latin1'); + expect($collation())->toBe('latin1_swedish_ci'); + + $tenantWithNullCharsetAndCollation = Tenant::create([ + 'tenancy_db_charset' => null, + 'tenancy_db_collation' => null, + ]); + + tenancy()->initialize($tenantWithNullCharsetAndCollation); + + // Default MySQL server charset and collation + expect($charset())->toBe('utf8mb4'); + expect($collation())->toBe('utf8mb4_0900_ai_ci'); + + $tenantWithCharsetAndNullCollation = Tenant::create([ + 'tenancy_db_charset' => 'binary', + 'tenancy_db_collation' => null, + ]); + + tenancy()->initialize($tenantWithCharsetAndNullCollation); + + // Charset specified, collation is null, + // MySQL will choose a default collation for the specified charset. + expect($charset())->toBe('binary'); + expect($collation())->toBe('binary'); + + // Collation specified, charset is null, + // MySQL will choose a default charset for the specified collation. + $tenantWithCollationAndNullCharset = Tenant::create([ + 'tenancy_db_charset' => null, + 'tenancy_db_collation' => 'latin1_swedish_ci', + ]); + + tenancy()->initialize($tenantWithCollationAndNullCharset); + + expect($charset())->toBe('latin1'); + expect($collation())->toBe('latin1_swedish_ci'); +}); + // Datasets dataset('database_managers', [ ['mysql', MySQLDatabaseManager::class], From 2b3466f95190d149061c7f7da54aa5c7ba0a6570 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 4 May 2026 11:48:55 +0200 Subject: [PATCH 47/97] Check the current DB name instead of configured one in harden() --- src/Bootstrappers/DatabaseTenancyBootstrapper.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Bootstrappers/DatabaseTenancyBootstrapper.php b/src/Bootstrappers/DatabaseTenancyBootstrapper.php index 30a55f73..ad713ce1 100644 --- a/src/Bootstrappers/DatabaseTenancyBootstrapper.php +++ b/src/Bootstrappers/DatabaseTenancyBootstrapper.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Stancl\Tenancy\Bootstrappers; use Exception; -use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Schema; use RuntimeException; use Stancl\Tenancy\Contracts\TenancyBootstrapper; @@ -13,6 +12,7 @@ use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; use Stancl\Tenancy\Database\DatabaseManager; use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException; +use Illuminate\Support\Facades\DB; class DatabaseTenancyBootstrapper implements TenancyBootstrapper { @@ -70,10 +70,9 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper protected function harden(Tenant $tenant): void { - /** @var TenantWithDatabase&Model $tenant */ - $dbName = $tenant->database()->getName(); + $dbName = DB::getDatabaseName(); - // Check if the current database is unique (i.e. no other tenant uses this database) + // Check if any other tenant uses this tenant's database if ($tenant::where($tenant->getTenantKeyName(), '!=', $tenant->getTenantKey()) ->where('data->tenancy_db_name', $dbName) ->exists()) { From 338526d9fb3b5ef6cda77a885f287debbd760f49 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 4 May 2026 11:54:45 +0200 Subject: [PATCH 48/97] Query for MySQL defaults instead of assuming them in charset test --- tests/TenantDatabaseManagerTest.php | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index cd5089a2..71207927 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -672,8 +672,11 @@ test('newly created tenant databases use the correct charset and collation with withBootstrapping(); - $charset = fn () => DB::selectOne('SELECT DEFAULT_CHARACTER_SET_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = DATABASE()')->DEFAULT_CHARACTER_SET_NAME; - $collation = fn () => DB::selectOne('SELECT DEFAULT_COLLATION_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = DATABASE()')->DEFAULT_COLLATION_NAME; + $serverDefaultCharset = DB::selectOne('SELECT @@character_set_server AS charset')->charset; + $serverDefaultCollation = DB::selectOne('SELECT @@collation_server AS collation')->collation; + + $databaseCharset = fn () => DB::selectOne('SELECT DEFAULT_CHARACTER_SET_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = DATABASE()')->DEFAULT_CHARACTER_SET_NAME; + $databaseCollation = fn () => DB::selectOne('SELECT DEFAULT_COLLATION_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = DATABASE()')->DEFAULT_COLLATION_NAME; $defaultTenant = Tenant::create(); @@ -681,8 +684,8 @@ test('newly created tenant databases use the correct charset and collation with // No charset or collation specified, // defaults from the MySQL config used. - expect($charset())->toBe('utf8mb4'); - expect($collation())->toBe('utf8mb4_unicode_ci'); + expect($databaseCharset())->toBe('utf8mb4'); + expect($databaseCollation())->toBe('utf8mb4_unicode_ci'); $tenantWithCharsetAndCollation = Tenant::create([ 'tenancy_db_charset' => 'latin1', @@ -692,8 +695,8 @@ test('newly created tenant databases use the correct charset and collation with tenancy()->initialize($tenantWithCharsetAndCollation); // Custom charset and collation from tenant config - expect($charset())->toBe('latin1'); - expect($collation())->toBe('latin1_swedish_ci'); + expect($databaseCharset())->toBe('latin1'); + expect($databaseCollation())->toBe('latin1_swedish_ci'); $tenantWithNullCharsetAndCollation = Tenant::create([ 'tenancy_db_charset' => null, @@ -703,8 +706,9 @@ test('newly created tenant databases use the correct charset and collation with tenancy()->initialize($tenantWithNullCharsetAndCollation); // Default MySQL server charset and collation - expect($charset())->toBe('utf8mb4'); - expect($collation())->toBe('utf8mb4_0900_ai_ci'); + // (e.g. charset = utf8mb4, collation = utf8mb4_0900_ai_ci) + expect($databaseCharset())->toBe($serverDefaultCharset); + expect($databaseCollation())->toBe($serverDefaultCollation); $tenantWithCharsetAndNullCollation = Tenant::create([ 'tenancy_db_charset' => 'binary', @@ -715,8 +719,8 @@ test('newly created tenant databases use the correct charset and collation with // Charset specified, collation is null, // MySQL will choose a default collation for the specified charset. - expect($charset())->toBe('binary'); - expect($collation())->toBe('binary'); + expect($databaseCharset())->toBe('binary'); + expect($databaseCollation())->toBe('binary'); // Collation specified, charset is null, // MySQL will choose a default charset for the specified collation. @@ -727,8 +731,8 @@ test('newly created tenant databases use the correct charset and collation with tenancy()->initialize($tenantWithCollationAndNullCharset); - expect($charset())->toBe('latin1'); - expect($collation())->toBe('latin1_swedish_ci'); + expect($databaseCharset())->toBe('latin1'); + expect($databaseCollation())->toBe('latin1_swedish_ci'); }); // Datasets From fec170ada91077e61db9ee97a2be4a4313944902 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 4 May 2026 09:55:13 +0000 Subject: [PATCH 49/97] Fix code style (php-cs-fixer) --- src/Bootstrappers/DatabaseTenancyBootstrapper.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Bootstrappers/DatabaseTenancyBootstrapper.php b/src/Bootstrappers/DatabaseTenancyBootstrapper.php index ad713ce1..3295daa5 100644 --- a/src/Bootstrappers/DatabaseTenancyBootstrapper.php +++ b/src/Bootstrappers/DatabaseTenancyBootstrapper.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Stancl\Tenancy\Bootstrappers; use Exception; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use RuntimeException; use Stancl\Tenancy\Contracts\TenancyBootstrapper; @@ -12,7 +13,6 @@ use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; use Stancl\Tenancy\Database\DatabaseManager; use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException; -use Illuminate\Support\Facades\DB; class DatabaseTenancyBootstrapper implements TenancyBootstrapper { From 98a808bb98658b16dd68808c8adce16165f59c52 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 4 May 2026 11:58:24 +0200 Subject: [PATCH 50/97] Quote schema names in GRANT statements PermissionControlledPostgreSQLDatabaseManager now uses the same quoting in GRANT statements as its schema counterpart. --- .../PermissionControlledPostgreSQLDatabaseManager.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLDatabaseManager.php index a10cfd2e..6b4856b0 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLDatabaseManager.php @@ -34,10 +34,10 @@ class PermissionControlledPostgreSQLDatabaseManager extends PostgreSQLDatabaseMa $this->connection()->reconnect(); // Grant permissions to create and use tables in the configured schema ("public" by default) to the user - $this->connection()->statement("GRANT USAGE, CREATE ON SCHEMA {$schema} TO \"{$username}\""); + $this->connection()->statement("GRANT USAGE, CREATE ON SCHEMA \"{$schema}\" TO \"{$username}\""); // Grant permissions to use sequences in the current schema to the user - $this->connection()->statement("GRANT USAGE ON ALL SEQUENCES IN SCHEMA {$schema} TO \"{$username}\""); + $this->connection()->statement("GRANT USAGE ON ALL SEQUENCES IN SCHEMA \"{$schema}\" TO \"{$username}\""); // Reconnect to central database config(["database.connections.{$connectionName}.database" => $centralDatabase]); From 6ed9975e859a6e0fab830cd0f2261b9a67345221 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 4 May 2026 12:14:39 +0200 Subject: [PATCH 51/97] Catch broader range of exceptions (harden() in DB bootstrapper) --- src/Bootstrappers/DatabaseTenancyBootstrapper.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Bootstrappers/DatabaseTenancyBootstrapper.php b/src/Bootstrappers/DatabaseTenancyBootstrapper.php index 3295daa5..6ff3576e 100644 --- a/src/Bootstrappers/DatabaseTenancyBootstrapper.php +++ b/src/Bootstrappers/DatabaseTenancyBootstrapper.php @@ -13,6 +13,7 @@ use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; use Stancl\Tenancy\Database\DatabaseManager; use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException; +use Throwable; class DatabaseTenancyBootstrapper implements TenancyBootstrapper { @@ -54,7 +55,7 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper if (static::$harden) { try { $this->harden($tenant); - } catch (RuntimeException $e) { + } catch (Throwable $e) { // Revert connection back to central $this->revert(); From de913486e01e28ee46221445774bdbaf9ab1428f Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 4 May 2026 12:27:46 +0200 Subject: [PATCH 52/97] Specify exception message in assertions --- tests/TenantDatabaseManagerTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index 71207927..2ae014f8 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -564,10 +564,10 @@ test('database managers validate parameters that cannot be bound', function ($dr ]); expect(fn () => $manager->createDatabase($tenant)) - ->toThrow(InvalidArgumentException::class); + ->toThrow(InvalidArgumentException::class, 'Forbidden character'); expect(fn () => $manager->deleteDatabase($tenant)) - ->toThrow(InvalidArgumentException::class); + ->toThrow(InvalidArgumentException::class, 'Forbidden character'); } else { // Invalid username, createUser() and deleteUser() should // throw an invalid argument exception. From bdbfbd45610dc7df0a462329cf4bfef7777682dc Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 4 May 2026 12:32:25 +0200 Subject: [PATCH 53/97] Remove extra variable --- tests/TenantDatabaseManagerTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index 2ae014f8..0c145503 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -656,9 +656,9 @@ test('sqlite database manager respects the configured path while making the data // SQLiteDatabaseManager::$path is null, the database path is built using database_path() expect($tenant->database()->connection()['database'])->toBe(database_path('tenant.sqlite')); - SQLiteDatabaseManager::$path = $customPath = '/custom/path/'; + SQLiteDatabaseManager::$path = '/custom/path/'; - expect($tenant->database()->connection()['database'])->toBe($customPath . 'tenant.sqlite'); + expect($tenant->database()->connection()['database'])->toBe('/custom/path/tenant.sqlite'); }); test('newly created tenant databases use the correct charset and collation with mysql', function () { From e59195eefe20c392d91f9180272eee97cbb22e54 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 4 May 2026 13:04:57 +0200 Subject: [PATCH 54/97] Improve coverage Cover non-string parameter validation and in-memory DB name validation --- tests/TenantDatabaseManagerTest.php | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index 0c145503..001dafa8 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -612,6 +612,20 @@ test('database managers validate parameters that cannot be bound', function ($dr // (an exception will be thrown, but not by validateParameter()). expect(fn () => $manager->createUser($tenantWithNullDbParameters->database())) ->not()->toThrow(InvalidArgumentException::class); + + if ($driver === 'mysql') { + // MySQLDatabaseManager gets the charset from the config + // Validation throws if the parameter is not a string + $tenantWithNonStringCharset = Tenant::make([ + 'tenancy_db_name' => 'valid_database_name890', + 'tenancy_db_username' => 'valid-username', + ]); + + config(['database.connections.mysql.charset' => []]); + + expect(fn () => $manager->createDatabase($tenantWithNonStringCharset)) + ->toThrow(InvalidArgumentException::class, 'Parameter has to be a string.'); + } } })->with('database_managers'); @@ -642,6 +656,16 @@ test('sqlite database manager recognizes inmemory databases correctly', function // Doesn't start with 'file:_tenancy_inmemory_' expect($manager->isInMemory('_tenancy_inmemory_123?mode=memory&cache=shared'))->toBeFalse(); + + // In-memory DB name is validated correctly in makeConnectionConfig() + expect(fn () => $manager->makeConnectionConfig([], 'file:_tenancy_inmemory_12"3?mode=memory&cache=shared')) + ->toThrow(InvalidArgumentException::class, 'Forbidden character'); + + expect(fn () => $manager->makeConnectionConfig([], 'file:_tenancy_inmemory_123?mode=memory&cache=shared')) + ->not()->toThrow(InvalidArgumentException::class); + + expect(fn () => $manager->makeConnectionConfig([], ':memory:')) + ->not()->toThrow(InvalidArgumentException::class); }); test('sqlite database manager respects the configured path while making the database config', function () { From 66ae88a32515de4dd8f647860d892b3c30d0bdb1 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 4 May 2026 13:26:01 +0200 Subject: [PATCH 55/97] Fix non-string parameter validation assertion --- tests/TenantDatabaseManagerTest.php | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index 001dafa8..feb24b12 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -568,6 +568,20 @@ test('database managers validate parameters that cannot be bound', function ($dr expect(fn () => $manager->deleteDatabase($tenant)) ->toThrow(InvalidArgumentException::class, 'Forbidden character'); + + if ($driver === 'mysql') { + // MySQLDatabaseManager reads charset/collation from config. + // An exception is thrown during validation if the parameter is not a string. + config(['database.connections.mysql.charset' => []]); + DB::purge('mysql'); + + $tenantWithNonStringCharset = Tenant::make([ + 'tenancy_db_name' => 'valid_db_name', + ]); + + expect(fn () => $manager->createDatabase($tenantWithNonStringCharset)) + ->toThrow(InvalidArgumentException::class, 'Parameter has to be a string.'); + } } else { // Invalid username, createUser() and deleteUser() should // throw an invalid argument exception. @@ -612,20 +626,6 @@ test('database managers validate parameters that cannot be bound', function ($dr // (an exception will be thrown, but not by validateParameter()). expect(fn () => $manager->createUser($tenantWithNullDbParameters->database())) ->not()->toThrow(InvalidArgumentException::class); - - if ($driver === 'mysql') { - // MySQLDatabaseManager gets the charset from the config - // Validation throws if the parameter is not a string - $tenantWithNonStringCharset = Tenant::make([ - 'tenancy_db_name' => 'valid_database_name890', - 'tenancy_db_username' => 'valid-username', - ]); - - config(['database.connections.mysql.charset' => []]); - - expect(fn () => $manager->createDatabase($tenantWithNonStringCharset)) - ->toThrow(InvalidArgumentException::class, 'Parameter has to be a string.'); - } } })->with('database_managers'); From 03318752b6b5eecad68948697db5d7a70c903998 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 4 May 2026 13:40:42 +0200 Subject: [PATCH 56/97] Specify charset and collation config in test --- tests/TenantDatabaseManagerTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index feb24b12..46196f80 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -688,6 +688,8 @@ test('sqlite database manager respects the configured path while making the data test('newly created tenant databases use the correct charset and collation with mysql', function () { config([ 'tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class], + 'database.connections.mysql.charset' => 'utf8mb4', + 'database.connections.mysql.collation' => 'utf8mb4_unicode_ci', ]); Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { From bbd8f6fd986893e51052fd17941f988f902f4730 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 4 May 2026 14:37:01 +0200 Subject: [PATCH 57/97] Add parentheses to instanceof check --- tests/TenantDatabaseManagerTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index 46196f80..8a591928 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -553,7 +553,7 @@ test('database managers validate parameters that cannot be bound', function ($dr $invalidDatabaseName = "\"database_with_quotes\""; - if (! $manager instanceof ManagesDatabaseUsers) { + if (! ($manager instanceof ManagesDatabaseUsers)) { // Only test createDatabase() and deleteDatabase() with non-permission controlled managers here // since permission controlled managers override these methods to e.g. delete users before // calling parent::deleteDatabase(), and with invalid DB name, the user deletion will already From 587f347b6495c1251f93ebd6f0a522358c614575 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 4 May 2026 15:19:12 +0200 Subject: [PATCH 58/97] Restore default charset after assertion --- tests/TenantDatabaseManagerTest.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index 8a591928..09d04815 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -581,6 +581,10 @@ test('database managers validate parameters that cannot be bound', function ($dr expect(fn () => $manager->createDatabase($tenantWithNonStringCharset)) ->toThrow(InvalidArgumentException::class, 'Parameter has to be a string.'); + + // Restore the default charset to avoid inconsistencies in future test runs + config(['database.connections.mysql.charset' => 'utf8mb4']); + DB::purge('mysql'); } } else { // Invalid username, createUser() and deleteUser() should From 099a666dbcb6aaab582833d9254ccd0b2c454091 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 4 May 2026 15:38:42 +0200 Subject: [PATCH 59/97] Add valid password assertion --- tests/TenantDatabaseManagerTest.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index 09d04815..855655ae 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -620,6 +620,16 @@ test('database managers validate parameters that cannot be bound', function ($dr expect(fn () => $manager->createUser($tenantWithInvalidPassword->database())) ->toThrow(InvalidArgumentException::class, 'Forbidden character'); + // Special characters are allowed in password + $tenantWithValidPassword = Tenant::make([ + 'tenancy_db_name' => 'valid_database_name890', + 'tenancy_db_username' => 'valid_USERNAME', + 'tenancy_db_password' => "]pa$$ ;word", + ]); + + expect(fn () => $manager->createUser($tenantWithValidPassword->database())) + ->not()->toThrow(InvalidArgumentException::class, 'Forbidden character'); + $tenantWithNullDbParameters = Tenant::make([ 'tenancy_db_name' => null, 'tenancy_db_username' => null, From 649c8027f44964c5294300613d89e2389f90ac9a Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 4 May 2026 17:14:16 +0200 Subject: [PATCH 60/97] Use unique DB names and passwords in test --- tests/TenantDatabaseManagerTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index 855655ae..c7af9a0c 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -622,8 +622,8 @@ test('database managers validate parameters that cannot be bound', function ($dr // Special characters are allowed in password $tenantWithValidPassword = Tenant::make([ - 'tenancy_db_name' => 'valid_database_name890', - 'tenancy_db_username' => 'valid_USERNAME', + 'tenancy_db_name' => 'valid_database_name890' . Str::random(4), + 'tenancy_db_username' => 'valid_USERNAME' . Str::random(4), 'tenancy_db_password' => "]pa$$ ;word", ]); From 519c819e281436cc4c4753b2d6e8d9560eb950dc Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 5 May 2026 08:30:18 +0200 Subject: [PATCH 61/97] Delete user created in validation test --- tests/TenantDatabaseManagerTest.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index c7af9a0c..2ff65460 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -582,7 +582,7 @@ test('database managers validate parameters that cannot be bound', function ($dr expect(fn () => $manager->createDatabase($tenantWithNonStringCharset)) ->toThrow(InvalidArgumentException::class, 'Parameter has to be a string.'); - // Restore the default charset to avoid inconsistencies in future test runs + // Restore the default charset config(['database.connections.mysql.charset' => 'utf8mb4']); DB::purge('mysql'); } @@ -620,7 +620,7 @@ test('database managers validate parameters that cannot be bound', function ($dr expect(fn () => $manager->createUser($tenantWithInvalidPassword->database())) ->toThrow(InvalidArgumentException::class, 'Forbidden character'); - // Special characters are allowed in password + // Special characters are allowed in passwords $tenantWithValidPassword = Tenant::make([ 'tenancy_db_name' => 'valid_database_name890' . Str::random(4), 'tenancy_db_username' => 'valid_USERNAME' . Str::random(4), @@ -630,6 +630,10 @@ test('database managers validate parameters that cannot be bound', function ($dr expect(fn () => $manager->createUser($tenantWithValidPassword->database())) ->not()->toThrow(InvalidArgumentException::class, 'Forbidden character'); + // Delete the created user + expect(fn () => $manager->deleteUser($tenantWithValidPassword->database())) + ->not()->toThrow(InvalidArgumentException::class); + $tenantWithNullDbParameters = Tenant::make([ 'tenancy_db_name' => null, 'tenancy_db_username' => null, From d9ae27425d51fb08f1b45c56b028a1f1f985fb3e Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 5 May 2026 10:06:41 +0200 Subject: [PATCH 62/97] Delete redundant cleanup --- tests/TenantDatabaseManagerTest.php | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index 2ff65460..4ca0d5fd 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -622,18 +622,14 @@ test('database managers validate parameters that cannot be bound', function ($dr // Special characters are allowed in passwords $tenantWithValidPassword = Tenant::make([ - 'tenancy_db_name' => 'valid_database_name890' . Str::random(4), - 'tenancy_db_username' => 'valid_USERNAME' . Str::random(4), + 'tenancy_db_name' => 'valid_database_name890' . Str::random(8), + 'tenancy_db_username' => 'valid_USERNAME' . Str::random(8), 'tenancy_db_password' => "]pa$$ ;word", ]); expect(fn () => $manager->createUser($tenantWithValidPassword->database())) ->not()->toThrow(InvalidArgumentException::class, 'Forbidden character'); - // Delete the created user - expect(fn () => $manager->deleteUser($tenantWithValidPassword->database())) - ->not()->toThrow(InvalidArgumentException::class); - $tenantWithNullDbParameters = Tenant::make([ 'tenancy_db_name' => null, 'tenancy_db_username' => null, From 6e82a9ee55d755b1c9ef36bc1d58da8b61f8d3fc Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 8 Jun 2026 09:06:10 +0200 Subject: [PATCH 63/97] Change @mixin annotations to @see --- src/Database/Concerns/ValidatesDatabaseParameters.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Database/Concerns/ValidatesDatabaseParameters.php b/src/Database/Concerns/ValidatesDatabaseParameters.php index 18af74c7..9488fee0 100644 --- a/src/Database/Concerns/ValidatesDatabaseParameters.php +++ b/src/Database/Concerns/ValidatesDatabaseParameters.php @@ -12,8 +12,8 @@ use InvalidArgumentException; * * Used where parameters can be provided by users, and where parameter binding cannot be used. * - * @mixin \Stancl\Tenancy\Database\TenantDatabaseManagers\TenantDatabaseManager - * @mixin \Stancl\Tenancy\Database\TenantDatabaseManagers\SQLiteDatabaseManager + * @see \Stancl\Tenancy\Database\TenantDatabaseManagers\TenantDatabaseManager + * @see \Stancl\Tenancy\Database\TenantDatabaseManagers\SQLiteDatabaseManager */ trait ValidatesDatabaseParameters { From b4244be6589b035b314f8ac8be1d5cfcb9c3e5ad Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 8 Jun 2026 10:02:49 +0200 Subject: [PATCH 64/97] Determine data column and internal prefix dynamically instead of hardcoding in harden() --- src/Bootstrappers/DatabaseTenancyBootstrapper.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Bootstrappers/DatabaseTenancyBootstrapper.php b/src/Bootstrappers/DatabaseTenancyBootstrapper.php index 6ff3576e..f27937d4 100644 --- a/src/Bootstrappers/DatabaseTenancyBootstrapper.php +++ b/src/Bootstrappers/DatabaseTenancyBootstrapper.php @@ -71,11 +71,12 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper protected function harden(Tenant $tenant): void { + /** @var \Stancl\Tenancy\Database\Models\Tenant $tenant */ $dbName = DB::getDatabaseName(); // Check if any other tenant uses this tenant's database if ($tenant::where($tenant->getTenantKeyName(), '!=', $tenant->getTenantKey()) - ->where('data->tenancy_db_name', $dbName) + ->where($tenant::getDataColumn() . '->' . $tenant->internalPrefix() . 'db_name', $dbName) ->exists()) { throw new RuntimeException('Tenant cannot use a database of another tenant.'); } From 42a2c8efd95e78092586d5f0fd926153550bf3f6 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 8 Jun 2026 10:03:07 +0200 Subject: [PATCH 65/97] Improve `$harden` annotation --- src/Bootstrappers/DatabaseTenancyBootstrapper.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Bootstrappers/DatabaseTenancyBootstrapper.php b/src/Bootstrappers/DatabaseTenancyBootstrapper.php index f27937d4..08ea5a74 100644 --- a/src/Bootstrappers/DatabaseTenancyBootstrapper.php +++ b/src/Bootstrappers/DatabaseTenancyBootstrapper.php @@ -20,6 +20,11 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper /** * When true, throw an exception if a tenant gets connected to * another tenant's database or to the central database. + * + * If tenant's database name can be set during tenant creation by the users, + * the user creates a tenant that could connect to the database of another tenant. + * Setting $harden to true prevents this, but we keep it false by default since + * letting users specify the tenant database names is not that common. */ public static bool $harden = false; From b7045c52d8f93cdf627ae660bf87381e03cfb080 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 8 Jun 2026 10:18:51 +0200 Subject: [PATCH 66/97] Rename harden() to verifyTenantCanUseDatabase() --- src/Bootstrappers/DatabaseTenancyBootstrapper.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Bootstrappers/DatabaseTenancyBootstrapper.php b/src/Bootstrappers/DatabaseTenancyBootstrapper.php index 08ea5a74..e731fea5 100644 --- a/src/Bootstrappers/DatabaseTenancyBootstrapper.php +++ b/src/Bootstrappers/DatabaseTenancyBootstrapper.php @@ -59,7 +59,7 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper if (static::$harden) { try { - $this->harden($tenant); + $this->verifyTenantCanUseDatabase($tenant); } catch (Throwable $e) { // Revert connection back to central $this->revert(); @@ -74,7 +74,7 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper $this->database->reconnectToCentral(); } - protected function harden(Tenant $tenant): void + protected function verifyTenantCanUseDatabase(Tenant $tenant): void { /** @var \Stancl\Tenancy\Database\Models\Tenant $tenant */ $dbName = DB::getDatabaseName(); From 4386a3b1a384f1444e834a1e6fe3cfe1a9d00001 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 8 Jun 2026 10:38:23 +0200 Subject: [PATCH 67/97] Improve annotations in ValidatesDatabaseParameters --- src/Database/Concerns/ValidatesDatabaseParameters.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Database/Concerns/ValidatesDatabaseParameters.php b/src/Database/Concerns/ValidatesDatabaseParameters.php index 9488fee0..fd242d45 100644 --- a/src/Database/Concerns/ValidatesDatabaseParameters.php +++ b/src/Database/Concerns/ValidatesDatabaseParameters.php @@ -22,6 +22,9 @@ trait ValidatesDatabaseParameters * * Used as the default allowlist in validateParameter(), which validates non-password * parameters such as database names or usernames. + * + * Since special characters are not used in non-password parameters commonly, + * we can be more strict about them to prevent SQL injection and other related issues. */ protected function allowedParameterCharacters(): string { @@ -31,10 +34,13 @@ trait ValidatesDatabaseParameters /** * Characters allowed in database user passwords. * - * Passwords are always quoted in the SQL statements, so it's safe + * Parameters are always quoted in the SQL statements, so it's safe * to allow a wider range of characters, as long as it doesn't include * characters that can break out of the quoted SQL strings (so e.g. * ', ", \, and ` aren't allowed). + * + * The allowlist is less strict for passwords than for other parameters + * because it's more common to use special characters in passwords. */ protected function allowedPasswordCharacters(): string { From 9ea085ef05f30a9e96bb7078ddb5f8cbeab6e5cf Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 8 Jun 2026 11:02:34 +0200 Subject: [PATCH 68/97] Improve wording Co-authored-by: Samuel Stancl --- src/Database/Concerns/ValidatesDatabaseParameters.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/Concerns/ValidatesDatabaseParameters.php b/src/Database/Concerns/ValidatesDatabaseParameters.php index fd242d45..cff2ef86 100644 --- a/src/Database/Concerns/ValidatesDatabaseParameters.php +++ b/src/Database/Concerns/ValidatesDatabaseParameters.php @@ -85,7 +85,7 @@ trait ValidatesDatabaseParameters /** * Ensure password only contains allowed characters (allowedPasswordCharacters()) - * before used in SQL statements. + * before being used in SQL statements. * * Used in permission controlled managers as a shorthand for calling validateParameter() * with the less strict allowlist to validate database user passwords. From f9636b15cff3620ee01991ef111051f2fa093cd7 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 8 Jun 2026 11:08:01 +0200 Subject: [PATCH 69/97] Use `Arr::wrap` instead of `(array)` --- src/Database/Concerns/ValidatesDatabaseParameters.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Database/Concerns/ValidatesDatabaseParameters.php b/src/Database/Concerns/ValidatesDatabaseParameters.php index cff2ef86..05c02fe0 100644 --- a/src/Database/Concerns/ValidatesDatabaseParameters.php +++ b/src/Database/Concerns/ValidatesDatabaseParameters.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Stancl\Tenancy\Database\Concerns; +use Illuminate\Support\Arr; use InvalidArgumentException; /** @@ -62,7 +63,7 @@ trait ValidatesDatabaseParameters { $allowedCharacters ??= $this->allowedParameterCharacters(); - foreach ((array) $parameters as $parameter) { + foreach (Arr::wrap($parameters) as $parameter) { if (is_null($parameter)) { // Skip if there's nothing to validate // (e.g. when $tenant->database()->getUsername() of an From b3111f1dde34bc4b0ab255be775c48f693622527 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 8 Jun 2026 11:17:59 +0200 Subject: [PATCH 70/97] Name second param passed to validateDatabaseName for clarity --- src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php index 129cdb37..499be9dd 100644 --- a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php @@ -137,7 +137,7 @@ class SQLiteDatabaseManager implements TenantDatabaseManager { if ($this->isInMemory($databaseName)) { // Named in-memory DBs are formatted like 'file:_tenancy_inmemory_tenant123?mode=memory&cache=shared' - $this->validateDatabaseName($databaseName, ':?=&'); + $this->validateDatabaseName($databaseName, extraAllowedCharacters: ':?=&'); $baseConfig['database'] = $databaseName; From 407197b1907981760f75b8fb32ac3e24b4bf154c Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 8 Jun 2026 11:26:07 +0200 Subject: [PATCH 71/97] Use datasets in hardening tests --- .../DatabaseTenancyBootstrapperTest.php | 48 +++++++++++++------ 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php b/tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php index 3030b8ed..ac4e47f7 100644 --- a/tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php @@ -28,12 +28,12 @@ beforeEach(function () use ($cleanup) { afterEach($cleanup); -test('harden prevents tenants from using the central database', function () { +test('harden prevents tenants from using the central database', function ($harden) { config([ 'tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class], ]); - DatabaseTenancyBootstrapper::$harden = true; + DatabaseTenancyBootstrapper::$harden = $harden; Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { return $event->tenant; @@ -45,19 +45,29 @@ test('harden prevents tenants from using the central database', function () { 'tenancy_db_name' => config('database.connections.central.database'), // Central database name ]); - // Harden blocks initialization for tenants that use central database - expect(fn () => tenancy()->initialize($tenant))->toThrow(RuntimeException::class); + if ($harden) { + // Harden blocks initialization for tenants that use central database + expect(fn () => tenancy()->initialize($tenant))->toThrow(RuntimeException::class); - // Connection should be reverted back to central - expect(DB::connection()->getName())->toBe('central'); -}); + // Connection should be reverted back to central + expect(DB::connection()->getName())->toBe('central'); + } else { + expect(fn() => tenancy()->initialize($tenant))->not()->toThrow(Throwable::class); -test('harden prevents tenants from using a database of another tenant', function () { + // Connection not reverted to central + expect(DB::connection()->getName())->toBe('tenant'); + } +})->with([ + 'hardening enabled' => true, + 'hardening disabled' => false, +]); + +test('harden prevents tenants from using a database of another tenant', function ($harden) { config([ 'tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class], ]); - DatabaseTenancyBootstrapper::$harden = true; + DatabaseTenancyBootstrapper::$harden = $harden; Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { return $event->tenant; @@ -73,12 +83,22 @@ test('harden prevents tenants from using a database of another tenant', function 'tenancy_db_name' => $tenantDbName, // Database of another tenant ]); - // Harden blocks initialization for tenants that use a database of another tenant - expect(fn () => tenancy()->initialize($tenant))->toThrow(RuntimeException::class); + if ($harden) { + // Harden blocks initialization for tenants that use a database of another tenant + expect(fn () => tenancy()->initialize($tenant))->toThrow(RuntimeException::class); - // Connection should be reverted back to central - expect(DB::connection()->getName())->toBe('central'); -}); + // Connection should be reverted back to central + expect(DB::connection()->getName())->toBe('central'); + } else { + expect(fn() => tenancy()->initialize($tenant))->not()->toThrow(Throwable::class); + + // Connection not reverted to central + expect(DB::connection()->getName())->toBe('tenant'); + } +})->with([ + 'hardening enabled' => true, + 'hardening disabled' => false, +]); test('database tenancy bootstrapper throws an exception if DATABASE_URL is set', function (string|null $databaseUrl) { config(['database.connections.central.url' => $databaseUrl]); From 49356a5513a9f66b8c43e29b76ca33736a5a73ac Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 8 Jun 2026 11:37:06 +0200 Subject: [PATCH 72/97] Use more specific exception assertions --- .../DatabaseTenancyBootstrapperTest.php | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php b/tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php index ac4e47f7..f91eee39 100644 --- a/tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php @@ -10,10 +10,11 @@ use Stancl\Tenancy\Events\TenantCreated; use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Tests\Etc\Tenant; - -use function Stancl\Tenancy\Tests\pest; use Illuminate\Support\Str; use Illuminate\Support\Facades\DB; +use Illuminate\Database\QueryException; + +use function Stancl\Tenancy\Tests\pest; $cleanup = function () { DatabaseTenancyBootstrapper::$harden = false; @@ -103,21 +104,21 @@ test('harden prevents tenants from using a database of another tenant', function test('database tenancy bootstrapper throws an exception if DATABASE_URL is set', function (string|null $databaseUrl) { config(['database.connections.central.url' => $databaseUrl]); - if ($databaseUrl) { - pest()->expectException(Exception::class); - } - config(['tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class]]); Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { return $event->tenant; })->toListener()); - $tenant1 = Tenant::create(); + if ($databaseUrl) { + expect(fn() => Tenant::create())->toThrow(QueryException::class); + } else { + expect(function() { + $tenant1 = Tenant::create(); - pest()->artisan('tenants:migrate'); + pest()->artisan('tenants:migrate'); - tenancy()->initialize($tenant1); - - expect(true)->toBe(true); + tenancy()->initialize($tenant1); + })->not()->toThrow(Throwable::class); + } })->with(['abc.us-east-1.rds.amazonaws.com', null]); From cf7e086bf770e7bec15eb640afc021dfcbf2553e Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 8 Jun 2026 11:42:03 +0200 Subject: [PATCH 73/97] Clean up `SQLiteDatabaseManager::$path` in `afterEach` --- tests/TenantDatabaseManagerTest.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index 4ca0d5fd..a500c7ff 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -37,6 +37,10 @@ beforeEach(function () { SQLiteDatabaseManager::$path = null; }); +afterEach(function () { + SQLiteDatabaseManager::$path = null; +}); + test('databases can be created and deleted', function ($driver, $databaseManager) { Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { return $event->tenant; From d28abdec892c8a1d6c2cd53d2212167b009e5730 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 8 Jun 2026 11:44:57 +0200 Subject: [PATCH 74/97] Delete redundant db username config --- tests/TenantDatabaseManagerTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index a500c7ff..a123b2d8 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -564,7 +564,6 @@ test('database managers validate parameters that cannot be bound', function ($dr // fail before we even get to actual deleteDatabase() logic. $tenant = Tenant::make([ 'tenancy_db_name' => $invalidDatabaseName, - 'tenancy_db_username' => 'valid-username', ]); expect(fn () => $manager->createDatabase($tenant)) From 36782ebee76c88dd55c0a016c74e873802e9c778 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 8 Jun 2026 11:51:35 +0200 Subject: [PATCH 75/97] Move mysql charset/collation validation assertions to a dedicated test --- tests/TenantDatabaseManagerTest.php | 33 +++++++++++++---------------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index a123b2d8..692528fe 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -571,24 +571,6 @@ test('database managers validate parameters that cannot be bound', function ($dr expect(fn () => $manager->deleteDatabase($tenant)) ->toThrow(InvalidArgumentException::class, 'Forbidden character'); - - if ($driver === 'mysql') { - // MySQLDatabaseManager reads charset/collation from config. - // An exception is thrown during validation if the parameter is not a string. - config(['database.connections.mysql.charset' => []]); - DB::purge('mysql'); - - $tenantWithNonStringCharset = Tenant::make([ - 'tenancy_db_name' => 'valid_db_name', - ]); - - expect(fn () => $manager->createDatabase($tenantWithNonStringCharset)) - ->toThrow(InvalidArgumentException::class, 'Parameter has to be a string.'); - - // Restore the default charset - config(['database.connections.mysql.charset' => 'utf8mb4']); - DB::purge('mysql'); - } } else { // Invalid username, createUser() and deleteUser() should // throw an invalid argument exception. @@ -646,6 +628,21 @@ test('database managers validate parameters that cannot be bound', function ($dr } })->with('database_managers'); +test('mysql database manager validates charset and collation correctly', function () { + $manager = app(MySQLDatabaseManager::class); + $manager->setConnection('mysql'); + + config(['database.connections.mysql.charset' => []]); + DB::purge('mysql'); + + $tenant = Tenant::make([ + 'tenancy_db_name' => 'valid_db_name', + ]); + + expect(fn () => $manager->createDatabase($tenant)) + ->toThrow(InvalidArgumentException::class, 'Parameter has to be a string.'); +}); + test('sqlite database manager validates database names correctly', function () { $manager = app(SQLiteDatabaseManager::class); From 3fb01768780adc14dcc84132ab7515aa9e8a0944 Mon Sep 17 00:00:00 2001 From: Samuel Stancl Date: Mon, 8 Jun 2026 14:54:50 -0700 Subject: [PATCH 76/97] improve mysql charset/collation validation test --- tests/TenantDatabaseManagerTest.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index 692528fe..8346442c 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -628,11 +628,12 @@ test('database managers validate parameters that cannot be bound', function ($dr } })->with('database_managers'); -test('mysql database manager validates charset and collation correctly', function () { +test('mysql database manager validates charset and collation correctly', function (string $param) { $manager = app(MySQLDatabaseManager::class); $manager->setConnection('mysql'); - config(['database.connections.mysql.charset' => []]); + // using a non-string value (empty array) which is invalid + config(["database.connections.mysql.$param" => []]); DB::purge('mysql'); $tenant = Tenant::make([ @@ -641,7 +642,7 @@ test('mysql database manager validates charset and collation correctly', functio expect(fn () => $manager->createDatabase($tenant)) ->toThrow(InvalidArgumentException::class, 'Parameter has to be a string.'); -}); +})->with(['charset', 'collation']); test('sqlite database manager validates database names correctly', function () { $manager = app(SQLiteDatabaseManager::class); From 88156d175df9f66bd6d8ce03dbe12fddfb0cb850 Mon Sep 17 00:00:00 2001 From: Samuel Stancl Date: Mon, 8 Jun 2026 15:08:58 -0700 Subject: [PATCH 77/97] improve test name --- tests/TenantDatabaseManagerTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index 8346442c..93417938 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -544,7 +544,7 @@ test('partial tenant connection templates get merged into the central connection expect($manager->connection()->getConfig('url'))->toBeNull(); }); -test('database managers validate parameters that cannot be bound', function ($driver, $databaseManager) { +test('database managers validate parameters used in raw sql statements', function ($driver, $databaseManager) { config()->set([ "tenancy.database.template_tenant_connection" => $driver, ]); From b3d11587aeeeb84343cb4c5768a153f5a3ed8536 Mon Sep 17 00:00:00 2001 From: Samuel Stancl Date: Mon, 8 Jun 2026 15:24:04 -0700 Subject: [PATCH 78/97] cast numeric params to string params --- src/Database/Concerns/ValidatesDatabaseParameters.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Database/Concerns/ValidatesDatabaseParameters.php b/src/Database/Concerns/ValidatesDatabaseParameters.php index 05c02fe0..a7d89855 100644 --- a/src/Database/Concerns/ValidatesDatabaseParameters.php +++ b/src/Database/Concerns/ValidatesDatabaseParameters.php @@ -71,6 +71,10 @@ trait ValidatesDatabaseParameters continue; } + if (is_numeric($parameter)) { + $parameter = (string) $parameter; + } + if (! is_string($parameter)) { // E.g. if a parameter is retrieved from the config, it isn't necessarily a string throw new InvalidArgumentException('Parameter has to be a string.'); From 13e32dd6ab3970126bf4623c390ae37192866b77 Mon Sep 17 00:00:00 2001 From: Samuel Stancl Date: Mon, 8 Jun 2026 15:49:30 -0700 Subject: [PATCH 79/97] update docblock on $harden --- .../DatabaseTenancyBootstrapper.php | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/Bootstrappers/DatabaseTenancyBootstrapper.php b/src/Bootstrappers/DatabaseTenancyBootstrapper.php index e731fea5..d6becad2 100644 --- a/src/Bootstrappers/DatabaseTenancyBootstrapper.php +++ b/src/Bootstrappers/DatabaseTenancyBootstrapper.php @@ -21,10 +21,24 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper * When true, throw an exception if a tenant gets connected to * another tenant's database or to the central database. * - * If tenant's database name can be set during tenant creation by the users, - * the user creates a tenant that could connect to the database of another tenant. - * Setting $harden to true prevents this, but we keep it false by default since - * letting users specify the tenant database names is not that common. + * This case should never come up in well-configured apps where + * users cannot set or edit tenant IDs or database names, so this + * option is disabled by default. + * + * However, applications dealing with extremely sensitive data may + * choose to enable this runtime check to prevent a bug or misconfiguration + * from creating an exploit that would let an attacker access another + * tenant's data or data from the central database. + * + * One way such a scenario might come up is if an application allows + * broad tenant attribute updates on a page for updating some fields + * on the tenant, without restricting that action to only a limited + * set of fields that are safe to edit. An attacker might be able to add + * something like ['tenancy_db_name' => '...'] to the request which could + * lead to this internal attribute being updated on an existing tenant. + * + * It's possible that enabling this setting will negate the performance + * benefits of cached tenant lookup. */ public static bool $harden = false; From fbffeb84b31b542a9ede5e5a7d290320036bba1e Mon Sep 17 00:00:00 2001 From: Samuel Stancl Date: Mon, 8 Jun 2026 16:20:19 -0700 Subject: [PATCH 80/97] improve docblocks for allowlists --- .../Concerns/ValidatesDatabaseParameters.php | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/Database/Concerns/ValidatesDatabaseParameters.php b/src/Database/Concerns/ValidatesDatabaseParameters.php index a7d89855..f1ed008b 100644 --- a/src/Database/Concerns/ValidatesDatabaseParameters.php +++ b/src/Database/Concerns/ValidatesDatabaseParameters.php @@ -24,8 +24,8 @@ trait ValidatesDatabaseParameters * Used as the default allowlist in validateParameter(), which validates non-password * parameters such as database names or usernames. * - * Since special characters are not used in non-password parameters commonly, - * we can be more strict about them to prevent SQL injection and other related issues. + * Since non-password parameters don't need to use as many special characters, we use + * a stricter allowlist here. */ protected function allowedParameterCharacters(): string { @@ -35,13 +35,8 @@ trait ValidatesDatabaseParameters /** * Characters allowed in database user passwords. * - * Parameters are always quoted in the SQL statements, so it's safe - * to allow a wider range of characters, as long as it doesn't include - * characters that can break out of the quoted SQL strings (so e.g. - * ', ", \, and ` aren't allowed). - * - * The allowlist is less strict for passwords than for other parameters - * because it's more common to use special characters in passwords. + * The allowlist for passwords is less strict than for other parameters + * because it's more common to use more special characters in passwords. */ protected function allowedPasswordCharacters(): string { From 48b8aac42d6893ee025f103f5d6b9e5a48f571e5 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 9 Jun 2026 08:15:07 +0200 Subject: [PATCH 81/97] Consider null parameters invalid Parameters passed to validateParameter should always be non-null, and if they're null, an exception is thrown. --- src/Database/Concerns/ValidatesDatabaseParameters.php | 11 +++++------ .../TenantDatabaseManagers/MySQLDatabaseManager.php | 2 +- tests/TenantDatabaseManagerTest.php | 11 +++++------ 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/Database/Concerns/ValidatesDatabaseParameters.php b/src/Database/Concerns/ValidatesDatabaseParameters.php index f1ed008b..5918f5bc 100644 --- a/src/Database/Concerns/ValidatesDatabaseParameters.php +++ b/src/Database/Concerns/ValidatesDatabaseParameters.php @@ -50,20 +50,19 @@ trait ValidatesDatabaseParameters * * By default, only the characters in allowedParameterCharacters() are allowed. * - * Null parameters are skipped. - * * @throws InvalidArgumentException */ protected function validateParameter(string|array|null $parameters, string|null $allowedCharacters = null): void { + if ($parameters === null) { + throw new InvalidArgumentException('Parameter cannot be null.'); + } + $allowedCharacters ??= $this->allowedParameterCharacters(); foreach (Arr::wrap($parameters) as $parameter) { if (is_null($parameter)) { - // Skip if there's nothing to validate - // (e.g. when $tenant->database()->getUsername() of an - // improperly created tenant is null and it gets passed). - continue; + throw new InvalidArgumentException('Parameter cannot be null.'); } if (is_numeric($parameter)) { diff --git a/src/Database/TenantDatabaseManagers/MySQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/MySQLDatabaseManager.php index ff61a3e8..912533b8 100644 --- a/src/Database/TenantDatabaseManagers/MySQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/MySQLDatabaseManager.php @@ -14,7 +14,7 @@ class MySQLDatabaseManager extends TenantDatabaseManager $charset = $this->connection()->getConfig('charset'); $collation = $this->connection()->getConfig('collation'); - $this->validateParameter([$database, $charset, $collation]); + $this->validateParameter(array_filter([$database, $charset, $collation], fn ($param) => $param !== null)); // MySQL defaults to the server's charset and collation // if charset and collation are not specified. diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index 93417938..d95cb1f0 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -591,6 +591,7 @@ test('database managers validate parameters used in raw sql statements', functio $tenantWithInvalidDatabase = Tenant::make([ 'tenancy_db_name' => $invalidDatabaseName, 'tenancy_db_username' => 'valid_USERNAME', + 'tenancy_db_password' => 'valid_password', ]); expect(fn () => $manager->createUser($tenantWithInvalidDatabase->database())) @@ -615,16 +616,14 @@ test('database managers validate parameters used in raw sql statements', functio expect(fn () => $manager->createUser($tenantWithValidPassword->database())) ->not()->toThrow(InvalidArgumentException::class, 'Forbidden character'); - $tenantWithNullDbParameters = Tenant::make([ - 'tenancy_db_name' => null, + $tenantWithNullCredentials = Tenant::make([ + 'tenancy_db_name' => 'valid_db_name', 'tenancy_db_username' => null, 'tenancy_db_password' => null, ]); - // validateParameter() doesn't throw InvalidArgumentException if a parameter is null - // (an exception will be thrown, but not by validateParameter()). - expect(fn () => $manager->createUser($tenantWithNullDbParameters->database())) - ->not()->toThrow(InvalidArgumentException::class); + expect(fn () => $manager->createUser($tenantWithNullCredentials->database())) + ->toThrow(InvalidArgumentException::class, 'Parameter cannot be null.'); } })->with('database_managers'); From 565bc41bf33766507e50a81a5657a8f48b30fb47 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 9 Jun 2026 09:02:31 +0200 Subject: [PATCH 82/97] Use a more specific central db check in the hardening feature Instead of just checking the presence of the tenants table on the current connection to determine if the table is/isn't tenant, check the current database's name, and if it's the central DB name, throw the runtime exception. --- src/Bootstrappers/DatabaseTenancyBootstrapper.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Bootstrappers/DatabaseTenancyBootstrapper.php b/src/Bootstrappers/DatabaseTenancyBootstrapper.php index d6becad2..20848bd5 100644 --- a/src/Bootstrappers/DatabaseTenancyBootstrapper.php +++ b/src/Bootstrappers/DatabaseTenancyBootstrapper.php @@ -100,8 +100,12 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper throw new RuntimeException('Tenant cannot use a database of another tenant.'); } - // Check if the current database doesn't have the tenants table (i.e. it's not the central database) - if (Schema::hasTable($tenant->getTable())) { + // Check if the current database is not the central database + $centralDbName = DB::connection( + config('tenancy.database.central_connection', 'central') + )->getDatabaseName(); + + if ($dbName === $centralDbName) { throw new RuntimeException('Tenant cannot use the central database.'); } } From 7972da54753e78fd2c87129a40be84612177d12f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 9 Jun 2026 07:03:00 +0000 Subject: [PATCH 83/97] Fix code style (php-cs-fixer) --- src/Bootstrappers/DatabaseTenancyBootstrapper.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Bootstrappers/DatabaseTenancyBootstrapper.php b/src/Bootstrappers/DatabaseTenancyBootstrapper.php index 20848bd5..b23e4fe5 100644 --- a/src/Bootstrappers/DatabaseTenancyBootstrapper.php +++ b/src/Bootstrappers/DatabaseTenancyBootstrapper.php @@ -6,7 +6,6 @@ namespace Stancl\Tenancy\Bootstrappers; use Exception; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Schema; use RuntimeException; use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\Tenant; From 540e3635e27ade5cd36f2f7dde737d873fd2eda7 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 9 Jun 2026 09:53:26 +0200 Subject: [PATCH 84/97] Improve hardening Make hardening work correctly even for named SQLite DBs, also make the related test test named SQLite DBs instead of just MySQL (the SQLite dataset fails when the DatabaseTenancyBootstrapper changes get reverted). --- .../DatabaseTenancyBootstrapper.php | 6 +++--- .../DatabaseTenancyBootstrapperTest.php | 17 +++++++++-------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/Bootstrappers/DatabaseTenancyBootstrapper.php b/src/Bootstrappers/DatabaseTenancyBootstrapper.php index b23e4fe5..1a813d39 100644 --- a/src/Bootstrappers/DatabaseTenancyBootstrapper.php +++ b/src/Bootstrappers/DatabaseTenancyBootstrapper.php @@ -90,11 +90,11 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper protected function verifyTenantCanUseDatabase(Tenant $tenant): void { /** @var \Stancl\Tenancy\Database\Models\Tenant $tenant */ - $dbName = DB::getDatabaseName(); + $tenantDbName = $tenant->database()->getName(); // Check if any other tenant uses this tenant's database if ($tenant::where($tenant->getTenantKeyName(), '!=', $tenant->getTenantKey()) - ->where($tenant::getDataColumn() . '->' . $tenant->internalPrefix() . 'db_name', $dbName) + ->where($tenant::getDataColumn() . '->' . $tenant->internalPrefix() . 'db_name', $tenantDbName) ->exists()) { throw new RuntimeException('Tenant cannot use a database of another tenant.'); } @@ -104,7 +104,7 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper config('tenancy.database.central_connection', 'central') )->getDatabaseName(); - if ($dbName === $centralDbName) { + if (DB::getDatabaseName() === $centralDbName) { throw new RuntimeException('Tenant cannot use the central database.'); } } diff --git a/tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php b/tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php index f91eee39..ce78e4aa 100644 --- a/tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php @@ -63,7 +63,7 @@ test('harden prevents tenants from using the central database', function ($harde 'hardening disabled' => false, ]); -test('harden prevents tenants from using a database of another tenant', function ($harden) { +test('harden prevents tenants from using a database of another tenant', function (bool $harden, string $connection) { config([ 'tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class], ]); @@ -74,15 +74,13 @@ test('harden prevents tenants from using a database of another tenant', function return $event->tenant; })->toListener()); - $tenant = Tenant::create(); + $tenant = Tenant::create(['tenancy_db_connection' => $connection]); - Tenant::create([ - 'tenancy_db_name' => $tenantDbName = 'foo' . Str::random(8), - ]); + $dbName = Str::random(8) . ($connection === 'sqlite' ? '.sqlite' : ''); - $tenant->update([ - 'tenancy_db_name' => $tenantDbName, // Database of another tenant - ]); + Tenant::create(['tenancy_db_name' => $dbName, 'tenancy_db_connection' => $connection]); + + $tenant->update(['tenancy_db_name' => $dbName]); if ($harden) { // Harden blocks initialization for tenants that use a database of another tenant @@ -99,6 +97,9 @@ test('harden prevents tenants from using a database of another tenant', function })->with([ 'hardening enabled' => true, 'hardening disabled' => false, +])->with([ + 'mysql' => 'mysql', + 'named sqlite' => 'sqlite', ]); test('database tenancy bootstrapper throws an exception if DATABASE_URL is set', function (string|null $databaseUrl) { From 93f77a5881d5d6b6c1218dd418be5cece359093d Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 9 Jun 2026 10:00:28 +0200 Subject: [PATCH 85/97] Fix PHPStan error --- src/Bootstrappers/DatabaseTenancyBootstrapper.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Bootstrappers/DatabaseTenancyBootstrapper.php b/src/Bootstrappers/DatabaseTenancyBootstrapper.php index 1a813d39..47b442e5 100644 --- a/src/Bootstrappers/DatabaseTenancyBootstrapper.php +++ b/src/Bootstrappers/DatabaseTenancyBootstrapper.php @@ -89,7 +89,7 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper protected function verifyTenantCanUseDatabase(Tenant $tenant): void { - /** @var \Stancl\Tenancy\Database\Models\Tenant $tenant */ + /** @var \Stancl\Tenancy\Database\Models\Tenant&TenantWithDatabase $tenant */ $tenantDbName = $tenant->database()->getName(); // Check if any other tenant uses this tenant's database From 1ae7d58fab80a6c18c16c673ca2df36c3852aea2 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 9 Jun 2026 10:03:44 +0200 Subject: [PATCH 86/97] Convert allowlist methods into static properties --- .../Concerns/ValidatesDatabaseParameters.php | 14 ++++---------- .../SQLiteDatabaseManager.php | 7 ++----- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/src/Database/Concerns/ValidatesDatabaseParameters.php b/src/Database/Concerns/ValidatesDatabaseParameters.php index 5918f5bc..b66a31c7 100644 --- a/src/Database/Concerns/ValidatesDatabaseParameters.php +++ b/src/Database/Concerns/ValidatesDatabaseParameters.php @@ -27,10 +27,7 @@ trait ValidatesDatabaseParameters * Since non-password parameters don't need to use as many special characters, we use * a stricter allowlist here. */ - protected function allowedParameterCharacters(): string - { - return 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-'; - } + public static string $allowedParameterCharacters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-'; /** * Characters allowed in database user passwords. @@ -38,10 +35,7 @@ trait ValidatesDatabaseParameters * The allowlist for passwords is less strict than for other parameters * because it's more common to use more special characters in passwords. */ - protected function allowedPasswordCharacters(): string - { - return ' !#$%&()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]^_abcdefghijklmnopqrstuvwxyz{|}~'; - } + public static string $allowedPasswordCharacters = ' !#$%&()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]^_abcdefghijklmnopqrstuvwxyz{|}~'; /** * Ensure that parameters (database names, usernames, etc.) @@ -58,7 +52,7 @@ trait ValidatesDatabaseParameters throw new InvalidArgumentException('Parameter cannot be null.'); } - $allowedCharacters ??= $this->allowedParameterCharacters(); + $allowedCharacters ??= static::$allowedParameterCharacters; foreach (Arr::wrap($parameters) as $parameter) { if (is_null($parameter)) { @@ -93,6 +87,6 @@ trait ValidatesDatabaseParameters */ protected function validatePassword(string|null $password): void { - $this->validateParameter($password, allowedCharacters: $this->allowedPasswordCharacters()); + $this->validateParameter($password, allowedCharacters: static::$allowedPasswordCharacters); } } diff --git a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php index 499be9dd..ce3582c0 100644 --- a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php @@ -66,10 +66,7 @@ class SQLiteDatabaseManager implements TenantDatabaseManager * * Includes dots to support file extensions (e.g. '.sqlite'). */ - protected static function allowedDatabaseNameCharacters(): string - { - return 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.'; - } + public static string $allowedDatabaseNameCharacters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.'; public function createDatabase(TenantWithDatabase $tenant): bool { @@ -179,7 +176,7 @@ class SQLiteDatabaseManager implements TenantDatabaseManager */ protected function validateDatabaseName(string $name, string $extraAllowedCharacters = ''): void { - $this->validateParameter($name, $this->allowedDatabaseNameCharacters() . $extraAllowedCharacters); + $this->validateParameter($name, static::$allowedDatabaseNameCharacters . $extraAllowedCharacters); if ($name === '') { throw new InvalidArgumentException('Database name cannot be empty.'); From 0fdc59dc5dcba5b2f5a7cdc60a7e309f94f9ae14 Mon Sep 17 00:00:00 2001 From: Samuel Stancl Date: Tue, 9 Jun 2026 16:10:47 -0700 Subject: [PATCH 87/97] comment grammar --- src/Bootstrappers/DatabaseTenancyBootstrapper.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Bootstrappers/DatabaseTenancyBootstrapper.php b/src/Bootstrappers/DatabaseTenancyBootstrapper.php index 47b442e5..ac1c87bc 100644 --- a/src/Bootstrappers/DatabaseTenancyBootstrapper.php +++ b/src/Bootstrappers/DatabaseTenancyBootstrapper.php @@ -92,14 +92,14 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper /** @var \Stancl\Tenancy\Database\Models\Tenant&TenantWithDatabase $tenant */ $tenantDbName = $tenant->database()->getName(); - // Check if any other tenant uses this tenant's database + // Check that no other tenant uses this tenant's database if ($tenant::where($tenant->getTenantKeyName(), '!=', $tenant->getTenantKey()) ->where($tenant::getDataColumn() . '->' . $tenant->internalPrefix() . 'db_name', $tenantDbName) ->exists()) { throw new RuntimeException('Tenant cannot use a database of another tenant.'); } - // Check if the current database is not the central database + // Check that the current database is not the central database $centralDbName = DB::connection( config('tenancy.database.central_connection', 'central') )->getDatabaseName(); From a7aa03158de643bb7a6ba7dc9c36a3ae9d101bfa Mon Sep 17 00:00:00 2001 From: Samuel Stancl Date: Tue, 9 Jun 2026 18:38:56 -0700 Subject: [PATCH 88/97] refactor: only accept single values in validateParameter() this is to make handling null easier (previous Arr::wrap() approach turned null into an empty array rather than [null] requiring two separate null checks) and testing easier (we use empty arrays as examples of invalid values in some tests which would previously be accepted when passed individually as validateParameter([]) rather than being part of a wider [something, [], ...] array) also restrict passing null to validatePassword() also minor grammar fix in the validateParameter() docblock --- .../Concerns/ValidatesDatabaseParameters.php | 42 +++++++++---------- .../MySQLDatabaseManager.php | 4 +- ...olledMicrosoftSQLServerDatabaseManager.php | 3 +- ...rmissionControlledMySQLDatabaseManager.php | 3 +- ...ionControlledPostgreSQLDatabaseManager.php | 4 +- ...ssionControlledPostgreSQLSchemaManager.php | 4 +- 6 files changed, 33 insertions(+), 27 deletions(-) diff --git a/src/Database/Concerns/ValidatesDatabaseParameters.php b/src/Database/Concerns/ValidatesDatabaseParameters.php index b66a31c7..412af16a 100644 --- a/src/Database/Concerns/ValidatesDatabaseParameters.php +++ b/src/Database/Concerns/ValidatesDatabaseParameters.php @@ -38,40 +38,34 @@ trait ValidatesDatabaseParameters public static string $allowedPasswordCharacters = ' !#$%&()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]^_abcdefghijklmnopqrstuvwxyz{|}~'; /** - * Ensure that parameters (database names, usernames, etc.) - * only contain allowed characters before used in SQL statements + * Ensure that parameter (database name, username, etc.) + * only contains allowed characters before being used in SQL statements * (or paths in the case of SQLiteDatabaseManager). * * By default, only the characters in allowedParameterCharacters() are allowed. * * @throws InvalidArgumentException */ - protected function validateParameter(string|array|null $parameters, string|null $allowedCharacters = null): void + protected function validateParameter(mixed $parameter, string|null $allowedCharacters = null): void { - if ($parameters === null) { + if (is_null($parameter)) { throw new InvalidArgumentException('Parameter cannot be null.'); } + if (is_numeric($parameter)) { + $parameter = (string) $parameter; + } + + if (! is_string($parameter)) { + // E.g. if a parameter is retrieved from the config, it isn't necessarily a string + throw new InvalidArgumentException('Parameter has to be a string.'); + } + $allowedCharacters ??= static::$allowedParameterCharacters; - foreach (Arr::wrap($parameters) as $parameter) { - if (is_null($parameter)) { - throw new InvalidArgumentException('Parameter cannot be null.'); - } - - if (is_numeric($parameter)) { - $parameter = (string) $parameter; - } - - if (! is_string($parameter)) { - // E.g. if a parameter is retrieved from the config, it isn't necessarily a string - throw new InvalidArgumentException('Parameter has to be a string.'); - } - - foreach (str_split($parameter) as $character) { - if (! str_contains($allowedCharacters, $character)) { - throw new InvalidArgumentException("Forbidden character '{$character}' in parameter."); - } + foreach (str_split($parameter) as $character) { + if (! str_contains($allowedCharacters, $character)) { + throw new InvalidArgumentException("Forbidden character '{$character}' in parameter."); } } } @@ -87,6 +81,10 @@ trait ValidatesDatabaseParameters */ protected function validatePassword(string|null $password): void { + if (is_null($password)) { + throw new InvalidArgumentException('Parameter cannot be null.'); + } + $this->validateParameter($password, allowedCharacters: static::$allowedPasswordCharacters); } } diff --git a/src/Database/TenantDatabaseManagers/MySQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/MySQLDatabaseManager.php index 912533b8..10385208 100644 --- a/src/Database/TenantDatabaseManagers/MySQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/MySQLDatabaseManager.php @@ -14,7 +14,7 @@ class MySQLDatabaseManager extends TenantDatabaseManager $charset = $this->connection()->getConfig('charset'); $collation = $this->connection()->getConfig('collation'); - $this->validateParameter(array_filter([$database, $charset, $collation], fn ($param) => $param !== null)); + $this->validateParameter($database); // MySQL defaults to the server's charset and collation // if charset and collation are not specified. @@ -23,10 +23,12 @@ class MySQLDatabaseManager extends TenantDatabaseManager $statement = "CREATE DATABASE `{$database}`"; if ($charset !== null) { + $this->validateParameter($charset); $statement .= " CHARACTER SET `{$charset}`"; } if ($collation !== null) { + $this->validateParameter($collation); $statement .= " COLLATE `{$collation}`"; } diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledMicrosoftSQLServerDatabaseManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledMicrosoftSQLServerDatabaseManager.php index cbc43d18..b10b82bc 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledMicrosoftSQLServerDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledMicrosoftSQLServerDatabaseManager.php @@ -24,7 +24,8 @@ class PermissionControlledMicrosoftSQLServerDatabaseManager extends MicrosoftSQL $username = $databaseConfig->getUsername(); $password = $databaseConfig->getPassword(); - $this->validateParameter([$database, $username]); + $this->validateParameter($database); + $this->validateParameter($username); $this->validatePassword($password); // Create login diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php index a4f74f8f..421b3bc3 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php @@ -25,7 +25,8 @@ class PermissionControlledMySQLDatabaseManager extends MySQLDatabaseManager impl $username = $databaseConfig->getUsername(); $password = $databaseConfig->getPassword(); - $this->validateParameter([$database, $username]); + $this->validateParameter($database); + $this->validateParameter($username); $this->validatePassword($password); $this->connection()->statement("CREATE USER `{$username}`@`%` IDENTIFIED BY '{$password}'"); diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLDatabaseManager.php index 6b4856b0..c846ace9 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLDatabaseManager.php @@ -20,7 +20,9 @@ class PermissionControlledPostgreSQLDatabaseManager extends PostgreSQLDatabaseMa $username = $databaseConfig->getUsername(); $schema = $databaseConfig->connection()['search_path']; - $this->validateParameter([$database, $username, $schema]); + $this->validateParameter($database); + $this->validateParameter($username); + $this->validateParameter($schema); // Host config $connectionName = $this->connection()->getConfig('name'); diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php index 057726b0..b972ba0b 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php @@ -23,7 +23,9 @@ class PermissionControlledPostgreSQLSchemaManager extends PostgreSQLSchemaManage // Central database name $database = DB::connection(config('tenancy.database.central_connection'))->getDatabaseName(); - $this->validateParameter([$username, $schema, $database]); + $this->validateParameter($username); + $this->validateParameter($schema); + $this->validateParameter($database); $this->connection()->statement("GRANT CONNECT ON DATABASE \"{$database}\" TO \"{$username}\""); $this->connection()->statement("GRANT USAGE, CREATE ON SCHEMA \"{$schema}\" TO \"{$username}\""); From 4760ed9375d46ffd543910978e4c2235af0950c0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 10 Jun 2026 01:56:43 +0000 Subject: [PATCH 89/97] Fix code style (php-cs-fixer) --- src/Database/Concerns/ValidatesDatabaseParameters.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Database/Concerns/ValidatesDatabaseParameters.php b/src/Database/Concerns/ValidatesDatabaseParameters.php index 412af16a..4ef34519 100644 --- a/src/Database/Concerns/ValidatesDatabaseParameters.php +++ b/src/Database/Concerns/ValidatesDatabaseParameters.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Stancl\Tenancy\Database\Concerns; -use Illuminate\Support\Arr; use InvalidArgumentException; /** From c9fa41111d5e1fcd53b3b97eea9e4c883bb8a887 Mon Sep 17 00:00:00 2001 From: Samuel Stancl Date: Tue, 9 Jun 2026 19:05:31 -0700 Subject: [PATCH 90/97] update docblocks (methods were changed to static props), validate empty strings --- .../Concerns/ValidatesDatabaseParameters.php | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/Database/Concerns/ValidatesDatabaseParameters.php b/src/Database/Concerns/ValidatesDatabaseParameters.php index 4ef34519..d6d01a03 100644 --- a/src/Database/Concerns/ValidatesDatabaseParameters.php +++ b/src/Database/Concerns/ValidatesDatabaseParameters.php @@ -41,7 +41,7 @@ trait ValidatesDatabaseParameters * only contains allowed characters before being used in SQL statements * (or paths in the case of SQLiteDatabaseManager). * - * By default, only the characters in allowedParameterCharacters() are allowed. + * By default, only the characters in $allowedParameterCharacters are allowed. * * @throws InvalidArgumentException */ @@ -56,10 +56,13 @@ trait ValidatesDatabaseParameters } if (! is_string($parameter)) { - // E.g. if a parameter is retrieved from the config, it isn't necessarily a string throw new InvalidArgumentException('Parameter has to be a string.'); } + if ($parameter === '') { + throw new InvalidArgumentException('Parameter cannot be an empty string.'); + } + $allowedCharacters ??= static::$allowedParameterCharacters; foreach (str_split($parameter) as $character) { @@ -70,7 +73,7 @@ trait ValidatesDatabaseParameters } /** - * Ensure password only contains allowed characters (allowedPasswordCharacters()) + * Ensure password only contains allowed characters ($allowedPasswordCharacters) * before being used in SQL statements. * * Used in permission controlled managers as a shorthand for calling validateParameter() @@ -81,7 +84,11 @@ trait ValidatesDatabaseParameters protected function validatePassword(string|null $password): void { if (is_null($password)) { - throw new InvalidArgumentException('Parameter cannot be null.'); + throw new InvalidArgumentException('Password cannot be null.'); + } + + if ($password === '') { + throw new InvalidArgumentException('Password cannot be an empty string.'); } $this->validateParameter($password, allowedCharacters: static::$allowedPasswordCharacters); From 028b985e5450724bbdac5f4818e25d784044909f Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 10 Jun 2026 07:56:30 +0200 Subject: [PATCH 91/97] Improve annotations Add newline after @var tenant annotation, make usage of`DB::getDatabaseName clearer. --- src/Bootstrappers/DatabaseTenancyBootstrapper.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Bootstrappers/DatabaseTenancyBootstrapper.php b/src/Bootstrappers/DatabaseTenancyBootstrapper.php index ac1c87bc..67e3cff3 100644 --- a/src/Bootstrappers/DatabaseTenancyBootstrapper.php +++ b/src/Bootstrappers/DatabaseTenancyBootstrapper.php @@ -90,6 +90,7 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper protected function verifyTenantCanUseDatabase(Tenant $tenant): void { /** @var \Stancl\Tenancy\Database\Models\Tenant&TenantWithDatabase $tenant */ + $tenantDbName = $tenant->database()->getName(); // Check that no other tenant uses this tenant's database @@ -99,12 +100,13 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper throw new RuntimeException('Tenant cannot use a database of another tenant.'); } - // Check that the current database is not the central database $centralDbName = DB::connection( config('tenancy.database.central_connection', 'central') )->getDatabaseName(); if (DB::getDatabaseName() === $centralDbName) { + // Throw if the current database is central. + // DB::getDatabaseName() is the current DB name, which should not be central at this point. throw new RuntimeException('Tenant cannot use the central database.'); } } From b743720c7c8d8156108214568c30d414b904aab5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 10 Jun 2026 05:57:06 +0000 Subject: [PATCH 92/97] Fix code style (php-cs-fixer) --- src/Bootstrappers/DatabaseTenancyBootstrapper.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Bootstrappers/DatabaseTenancyBootstrapper.php b/src/Bootstrappers/DatabaseTenancyBootstrapper.php index 67e3cff3..906b335a 100644 --- a/src/Bootstrappers/DatabaseTenancyBootstrapper.php +++ b/src/Bootstrappers/DatabaseTenancyBootstrapper.php @@ -90,7 +90,6 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper protected function verifyTenantCanUseDatabase(Tenant $tenant): void { /** @var \Stancl\Tenancy\Database\Models\Tenant&TenantWithDatabase $tenant */ - $tenantDbName = $tenant->database()->getName(); // Check that no other tenant uses this tenant's database From 34d19e94e208029834dad490905df091f44f82c1 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 10 Jun 2026 13:17:17 +0200 Subject: [PATCH 93/97] Make hardening work with all db/schema managers Previously, hardening only worked with databases, not with schemas. Also test that hardening works with all relevant db managers. --- .../DatabaseTenancyBootstrapper.php | 17 ++++--- .../Contracts/TenantDatabaseManager.php | 10 ++++ .../PostgreSQLSchemaManager.php | 7 +++ .../SQLiteDatabaseManager.php | 6 +++ .../TenantDatabaseManager.php | 5 ++ .../DatabaseTenancyBootstrapperTest.php | 51 ++++++++++++++----- 6 files changed, 76 insertions(+), 20 deletions(-) diff --git a/src/Bootstrappers/DatabaseTenancyBootstrapper.php b/src/Bootstrappers/DatabaseTenancyBootstrapper.php index 906b335a..a5eb6f81 100644 --- a/src/Bootstrappers/DatabaseTenancyBootstrapper.php +++ b/src/Bootstrappers/DatabaseTenancyBootstrapper.php @@ -90,7 +90,9 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper protected function verifyTenantCanUseDatabase(Tenant $tenant): void { /** @var \Stancl\Tenancy\Database\Models\Tenant&TenantWithDatabase $tenant */ - $tenantDbName = $tenant->database()->getName(); + + $tenantDbConfig = $tenant->database(); + $tenantDbName = $tenantDbConfig->getName(); // Check that no other tenant uses this tenant's database if ($tenant::where($tenant->getTenantKeyName(), '!=', $tenant->getTenantKey()) @@ -99,13 +101,14 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper throw new RuntimeException('Tenant cannot use a database of another tenant.'); } - $centralDbName = DB::connection( - config('tenancy.database.central_connection', 'central') - )->getDatabaseName(); + $manager = $tenantDbConfig->manager(); - if (DB::getDatabaseName() === $centralDbName) { - // Throw if the current database is central. - // DB::getDatabaseName() is the current DB name, which should not be central at this point. + $centralConnection = DB::connection(config('tenancy.database.central_connection', 'central')); + $currentConnection = DB::connection(); + + // Throw if the current database/schema is central. + // At this point the connection should be the tenant's, so it should not match central. + if ($manager->getCurrentDatabaseName($currentConnection) === $manager->getCurrentDatabaseName($centralConnection)) { throw new RuntimeException('Tenant cannot use the central database.'); } } diff --git a/src/Database/Contracts/TenantDatabaseManager.php b/src/Database/Contracts/TenantDatabaseManager.php index 8b5007b9..f1ac6d5f 100644 --- a/src/Database/Contracts/TenantDatabaseManager.php +++ b/src/Database/Contracts/TenantDatabaseManager.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace Stancl\Tenancy\Database\Contracts; +use Illuminate\Database\Connection; + interface TenantDatabaseManager { /** Create a database. */ @@ -17,4 +19,12 @@ interface TenantDatabaseManager /** Construct a DB connection config array. */ public function makeConnectionConfig(array $baseConfig, string $databaseName): array; + + /** + * Get the schema/database name that the given connection points to. + * + * In database managers, this should return the *database* name of the passed connection, + * while in schema managers, this should return the *schema* name of the passed connection. + */ + public function getCurrentDatabaseName(Connection $connection): string; } diff --git a/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php b/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php index 354eb768..fdb294a2 100644 --- a/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php +++ b/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Stancl\Tenancy\Database\TenantDatabaseManagers; +use Illuminate\Database\Connection; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; class PostgreSQLSchemaManager extends TenantDatabaseManager @@ -37,4 +38,10 @@ class PostgreSQLSchemaManager extends TenantDatabaseManager return $baseConfig; } + + public function getCurrentDatabaseName(Connection $connection): string + { + // Get the *schema* name (not the database name) + return $connection->selectOne('SELECT current_schema() AS schema')->schema; + } } diff --git a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php index ce3582c0..9d749044 100644 --- a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Stancl\Tenancy\Database\TenantDatabaseManagers; use Closure; +use Illuminate\Database\Connection; use Illuminate\Database\Eloquent\Model; use InvalidArgumentException; use PDO; @@ -149,6 +150,11 @@ class SQLiteDatabaseManager implements TenantDatabaseManager return $baseConfig; } + public function getCurrentDatabaseName(Connection $connection): string + { + return $connection->getDatabaseName(); + } + public function getPath(string $name): string { $this->validateDatabaseName($name); diff --git a/src/Database/TenantDatabaseManagers/TenantDatabaseManager.php b/src/Database/TenantDatabaseManagers/TenantDatabaseManager.php index a0822615..0dc4e642 100644 --- a/src/Database/TenantDatabaseManagers/TenantDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/TenantDatabaseManager.php @@ -37,4 +37,9 @@ abstract class TenantDatabaseManager implements StatefulTenantDatabaseManager return $baseConfig; } + + public function getCurrentDatabaseName(Connection $connection): string + { + return $connection->getDatabaseName(); + } } diff --git a/tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php b/tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php index ce78e4aa..8fd123b7 100644 --- a/tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php @@ -13,6 +13,10 @@ use Stancl\Tenancy\Tests\Etc\Tenant; use Illuminate\Support\Str; use Illuminate\Support\Facades\DB; use Illuminate\Database\QueryException; +use Stancl\Tenancy\Database\TenantDatabaseManagers\MySQLDatabaseManager; +use Stancl\Tenancy\Database\TenantDatabaseManagers\SQLiteDatabaseManager; +use Stancl\Tenancy\Database\TenantDatabaseManagers\PostgreSQLDatabaseManager; +use Stancl\Tenancy\Database\TenantDatabaseManagers\PostgreSQLSchemaManager; use function Stancl\Tenancy\Tests\pest; @@ -29,9 +33,21 @@ beforeEach(function () use ($cleanup) { afterEach($cleanup); -test('harden prevents tenants from using the central database', function ($harden) { +test('harden prevents tenants from using the central database', function (bool $harden, string $connection, string $manager) { config([ 'tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class], + "tenancy.database.managers.{$connection}" => $manager, + ]); + + // Set up and migrate the central database + $centralConnection = config('tenancy.database.central_connection'); + DB::purge($centralConnection); + config(["database.connections.{$centralConnection}" => config("database.connections.{$connection}")]); + + pest()->artisan('migrate:fresh', [ + '--force' => true, + '--path' => __DIR__ . '/../../assets/migrations', + '--realpath' => true, ]); DatabaseTenancyBootstrapper::$harden = $harden; @@ -40,20 +56,20 @@ test('harden prevents tenants from using the central database', function ($harde return $event->tenant; })->toListener()); - $tenant = Tenant::create(); - + // Create the tenant with its own database, then repoint it at the central database/schema. + $tenant = Tenant::create(['tenancy_db_connection' => $connection]); $tenant->update([ - 'tenancy_db_name' => config('database.connections.central.database'), // Central database name + 'tenancy_db_name' => $tenant->database()->manager()->getCurrentDatabaseName(DB::connection($centralConnection)), ]); if ($harden) { - // Harden blocks initialization for tenants that use central database + // Harden blocks initialization for tenants that use the central database expect(fn () => tenancy()->initialize($tenant))->toThrow(RuntimeException::class); // Connection should be reverted back to central - expect(DB::connection()->getName())->toBe('central'); + expect(DB::connection()->getName())->toBe($centralConnection); } else { - expect(fn() => tenancy()->initialize($tenant))->not()->toThrow(Throwable::class); + expect(fn () => tenancy()->initialize($tenant))->not()->toThrow(Throwable::class); // Connection not reverted to central expect(DB::connection()->getName())->toBe('tenant'); @@ -61,11 +77,12 @@ test('harden prevents tenants from using the central database', function ($harde })->with([ 'hardening enabled' => true, 'hardening disabled' => false, -]); +])->with('db_managers'); -test('harden prevents tenants from using a database of another tenant', function (bool $harden, string $connection) { +test('harden prevents tenants from using a database of another tenant', function (bool $harden, string $connection, string $manager) { config([ 'tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class], + "tenancy.database.managers.{$connection}" => $manager, ]); DatabaseTenancyBootstrapper::$harden = $harden; @@ -97,10 +114,7 @@ test('harden prevents tenants from using a database of another tenant', function })->with([ 'hardening enabled' => true, 'hardening disabled' => false, -])->with([ - 'mysql' => 'mysql', - 'named sqlite' => 'sqlite', -]); +])->with('db_managers'); test('database tenancy bootstrapper throws an exception if DATABASE_URL is set', function (string|null $databaseUrl) { config(['database.connections.central.url' => $databaseUrl]); @@ -123,3 +137,14 @@ test('database tenancy bootstrapper throws an exception if DATABASE_URL is set', })->not()->toThrow(Throwable::class); } })->with(['abc.us-east-1.rds.amazonaws.com', null]); + +// Database managers to test with hardening. +// Permission controlled managers omitted as they inherit the non-perm controlled managers (= they share the same code paths), +// each important code path is covered by testing the non-permission controlled manager, so adding permission controlled managers +// would add unnecessary complexity to the tests. +dataset('db_managers', [ + 'mysql' => ['mysql', MySQLDatabaseManager::class], + 'pgsql (database)' => ['pgsql', PostgreSQLDatabaseManager::class], + 'pgsql (schema)' => ['pgsql', PostgreSQLSchemaManager::class], + 'sqlite' => ['sqlite', SQLiteDatabaseManager::class], +]); From 397e9ecd934e0ad9827870f2cbfa29d17e14ff81 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 10 Jun 2026 11:22:13 +0000 Subject: [PATCH 94/97] Fix code style (php-cs-fixer) --- src/Bootstrappers/DatabaseTenancyBootstrapper.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Bootstrappers/DatabaseTenancyBootstrapper.php b/src/Bootstrappers/DatabaseTenancyBootstrapper.php index a5eb6f81..c09803c6 100644 --- a/src/Bootstrappers/DatabaseTenancyBootstrapper.php +++ b/src/Bootstrappers/DatabaseTenancyBootstrapper.php @@ -90,7 +90,6 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper protected function verifyTenantCanUseDatabase(Tenant $tenant): void { /** @var \Stancl\Tenancy\Database\Models\Tenant&TenantWithDatabase $tenant */ - $tenantDbConfig = $tenant->database(); $tenantDbName = $tenantDbConfig->getName(); From c0713d6e6615f9b8405bf831bee1c238d30abdb7 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 10 Jun 2026 13:23:59 +0200 Subject: [PATCH 95/97] Add newline after var annotation --- src/Bootstrappers/DatabaseTenancyBootstrapper.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Bootstrappers/DatabaseTenancyBootstrapper.php b/src/Bootstrappers/DatabaseTenancyBootstrapper.php index c09803c6..a5eb6f81 100644 --- a/src/Bootstrappers/DatabaseTenancyBootstrapper.php +++ b/src/Bootstrappers/DatabaseTenancyBootstrapper.php @@ -90,6 +90,7 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper protected function verifyTenantCanUseDatabase(Tenant $tenant): void { /** @var \Stancl\Tenancy\Database\Models\Tenant&TenantWithDatabase $tenant */ + $tenantDbConfig = $tenant->database(); $tenantDbName = $tenantDbConfig->getName(); From ac54c4f65b094f17da079b7d99ebca526563a327 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 10 Jun 2026 11:24:24 +0000 Subject: [PATCH 96/97] Fix code style (php-cs-fixer) --- src/Bootstrappers/DatabaseTenancyBootstrapper.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Bootstrappers/DatabaseTenancyBootstrapper.php b/src/Bootstrappers/DatabaseTenancyBootstrapper.php index a5eb6f81..c09803c6 100644 --- a/src/Bootstrappers/DatabaseTenancyBootstrapper.php +++ b/src/Bootstrappers/DatabaseTenancyBootstrapper.php @@ -90,7 +90,6 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper protected function verifyTenantCanUseDatabase(Tenant $tenant): void { /** @var \Stancl\Tenancy\Database\Models\Tenant&TenantWithDatabase $tenant */ - $tenantDbConfig = $tenant->database(); $tenantDbName = $tenantDbConfig->getName(); From 0d177c7e9bcf09a2d7025ffb863575fe01340f47 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 12 Jun 2026 15:34:39 +0200 Subject: [PATCH 97/97] Use simple tenants table check during hardening For a niche opt-in feature, checking if the tenants table is in the current schema is good enough for determining if the current DB is central -- the solution where we added getCurrentDatabaseName to each manager was overly complicated for this (though a bit more correct, not worth the added complexity). --- .../DatabaseTenancyBootstrapper.php | 15 +++-------- .../Contracts/TenantDatabaseManager.php | 10 ------- .../PostgreSQLSchemaManager.php | 7 ----- .../SQLiteDatabaseManager.php | 6 ----- .../TenantDatabaseManager.php | 5 ---- .../DatabaseTenancyBootstrapperTest.php | 26 ++++++++++++++----- 6 files changed, 24 insertions(+), 45 deletions(-) diff --git a/src/Bootstrappers/DatabaseTenancyBootstrapper.php b/src/Bootstrappers/DatabaseTenancyBootstrapper.php index c09803c6..08b36f1d 100644 --- a/src/Bootstrappers/DatabaseTenancyBootstrapper.php +++ b/src/Bootstrappers/DatabaseTenancyBootstrapper.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace Stancl\Tenancy\Bootstrappers; use Exception; -use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; use RuntimeException; use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\Tenant; @@ -90,8 +90,7 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper protected function verifyTenantCanUseDatabase(Tenant $tenant): void { /** @var \Stancl\Tenancy\Database\Models\Tenant&TenantWithDatabase $tenant */ - $tenantDbConfig = $tenant->database(); - $tenantDbName = $tenantDbConfig->getName(); + $tenantDbName = $tenant->database()->getName(); // Check that no other tenant uses this tenant's database if ($tenant::where($tenant->getTenantKeyName(), '!=', $tenant->getTenantKey()) @@ -100,14 +99,8 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper throw new RuntimeException('Tenant cannot use a database of another tenant.'); } - $manager = $tenantDbConfig->manager(); - - $centralConnection = DB::connection(config('tenancy.database.central_connection', 'central')); - $currentConnection = DB::connection(); - - // Throw if the current database/schema is central. - // At this point the connection should be the tenant's, so it should not match central. - if ($manager->getCurrentDatabaseName($currentConnection) === $manager->getCurrentDatabaseName($centralConnection)) { + if (Schema::hasTable($tenant->getTable())) { + // Throw if the current database/schema has the tenants table (i.e. it's not central) throw new RuntimeException('Tenant cannot use the central database.'); } } diff --git a/src/Database/Contracts/TenantDatabaseManager.php b/src/Database/Contracts/TenantDatabaseManager.php index f1ac6d5f..8b5007b9 100644 --- a/src/Database/Contracts/TenantDatabaseManager.php +++ b/src/Database/Contracts/TenantDatabaseManager.php @@ -4,8 +4,6 @@ declare(strict_types=1); namespace Stancl\Tenancy\Database\Contracts; -use Illuminate\Database\Connection; - interface TenantDatabaseManager { /** Create a database. */ @@ -19,12 +17,4 @@ interface TenantDatabaseManager /** Construct a DB connection config array. */ public function makeConnectionConfig(array $baseConfig, string $databaseName): array; - - /** - * Get the schema/database name that the given connection points to. - * - * In database managers, this should return the *database* name of the passed connection, - * while in schema managers, this should return the *schema* name of the passed connection. - */ - public function getCurrentDatabaseName(Connection $connection): string; } diff --git a/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php b/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php index fdb294a2..354eb768 100644 --- a/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php +++ b/src/Database/TenantDatabaseManagers/PostgreSQLSchemaManager.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Stancl\Tenancy\Database\TenantDatabaseManagers; -use Illuminate\Database\Connection; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; class PostgreSQLSchemaManager extends TenantDatabaseManager @@ -38,10 +37,4 @@ class PostgreSQLSchemaManager extends TenantDatabaseManager return $baseConfig; } - - public function getCurrentDatabaseName(Connection $connection): string - { - // Get the *schema* name (not the database name) - return $connection->selectOne('SELECT current_schema() AS schema')->schema; - } } diff --git a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php index 9d749044..ce3582c0 100644 --- a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Stancl\Tenancy\Database\TenantDatabaseManagers; use Closure; -use Illuminate\Database\Connection; use Illuminate\Database\Eloquent\Model; use InvalidArgumentException; use PDO; @@ -150,11 +149,6 @@ class SQLiteDatabaseManager implements TenantDatabaseManager return $baseConfig; } - public function getCurrentDatabaseName(Connection $connection): string - { - return $connection->getDatabaseName(); - } - public function getPath(string $name): string { $this->validateDatabaseName($name); diff --git a/src/Database/TenantDatabaseManagers/TenantDatabaseManager.php b/src/Database/TenantDatabaseManagers/TenantDatabaseManager.php index 0dc4e642..a0822615 100644 --- a/src/Database/TenantDatabaseManagers/TenantDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/TenantDatabaseManager.php @@ -37,9 +37,4 @@ abstract class TenantDatabaseManager implements StatefulTenantDatabaseManager return $baseConfig; } - - public function getCurrentDatabaseName(Connection $connection): string - { - return $connection->getDatabaseName(); - } } diff --git a/tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php b/tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php index 8fd123b7..fe09fca6 100644 --- a/tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/DatabaseTenancyBootstrapperTest.php @@ -39,10 +39,17 @@ test('harden prevents tenants from using the central database', function (bool $ "tenancy.database.managers.{$connection}" => $manager, ]); - // Set up and migrate the central database + // Point the central connection at the tested connection's config and migrate it + // (so that the central database/schema contains the tenants table). $centralConnection = config('tenancy.database.central_connection'); + $centralConfig = config("database.connections.{$connection}"); + + if ($connection === 'sqlite') { + $centralConfig['database'] = database_path($sqliteCentralDb = 'central.sqlite'); + } + DB::purge($centralConnection); - config(["database.connections.{$centralConnection}" => config("database.connections.{$connection}")]); + config(["database.connections.{$centralConnection}" => $centralConfig]); pest()->artisan('migrate:fresh', [ '--force' => true, @@ -56,11 +63,18 @@ test('harden prevents tenants from using the central database', function (bool $ return $event->tenant; })->toListener()); - // Create the tenant with its own database, then repoint it at the central database/schema. + // Create the tenant with its own database, then repoint it at the central database/schema + // (which contains the tenants table that the hardening check looks for). $tenant = Tenant::create(['tenancy_db_connection' => $connection]); - $tenant->update([ - 'tenancy_db_name' => $tenant->database()->manager()->getCurrentDatabaseName(DB::connection($centralConnection)), - ]); + + $central = DB::connection($centralConnection); + $centralName = match (true) { + $manager === PostgreSQLSchemaManager::class => $central->selectOne('SELECT current_schema() AS schema')->schema, // Central schema name + $connection === 'sqlite' => $sqliteCentralDb, // Central SQLite DB name + default => $central->getDatabaseName(), // Central DB name + }; + + $tenant->update(['tenancy_db_name' => $centralName]); if ($harden) { // Harden blocks initialization for tenants that use the central database