From 808f52765c669f5d5bb78a2a599a248b29ec4acc Mon Sep 17 00:00:00 2001 From: lukinovec Date: Wed, 29 Apr 2026 10:08:45 +0200 Subject: [PATCH 01/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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/54] 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 () {