Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
808f527
Use select() instead of selectOne() in databaseExists() and userExists()
lukinovec Apr 29, 2026
ad7d229
Use parameter binding in SELECT queries
lukinovec Apr 29, 2026
bdf592c
Add parameter validation to DB managers
lukinovec Apr 29, 2026
5adbc14
Test SQL parameter validation
lukinovec Apr 29, 2026
182f3a2
Fix code style (php-cs-fixer)
github-actions[bot] Apr 29, 2026
d5087d1
Extract parameter validation into a trait
lukinovec Apr 29, 2026
db03997
Validate SQLite DB names in create/deleteDatabase()
lukinovec Apr 29, 2026
0fdb8b2
Validate user passwords in DB managers
lukinovec Apr 29, 2026
4a3e6ba
Test invalid passwords, improve test name and comments
lukinovec Apr 29, 2026
740d53e
Rename ValidatesSqlParameters to ValidatesDatabaseParameters
lukinovec Apr 29, 2026
8592949
Improve ValidatesDatabaseParameters docblocks
lukinovec Apr 29, 2026
f3f1ab9
Skip null parameters in validateParameter
lukinovec Apr 30, 2026
75b74f2
Make validateParameter have void return type
lukinovec Apr 30, 2026
322257f
Validate SQLite filename in databaseExists
lukinovec Apr 30, 2026
46f73c4
Improve ValidatesDatabaseParameters comments, delete extra early return
lukinovec Apr 30, 2026
4bdb877
Cover null parameter skipping
lukinovec Apr 30, 2026
50ea524
Simplify test, improve comments
lukinovec Apr 30, 2026
bacbf93
Improve validation exception message
lukinovec Apr 30, 2026
37a4c7d
Check if paremeter is string
lukinovec Apr 30, 2026
2bd3a86
Quote database parameter in GRANT statement for consistency
lukinovec Apr 30, 2026
76c324d
Add `validateFilename()`
lukinovec May 1, 2026
d3607f8
Use 'allowedCharacters' instead of 'allowlist', code quality
lukinovec May 1, 2026
e8168eb
Add string check to validateFilename, swap validation order
lukinovec May 1, 2026
9611a05
Skip null parameters, throw for other non-string parameters
lukinovec May 1, 2026
f3836cc
Fix code style (php-cs-fixer)
github-actions[bot] May 1, 2026
2bdda23
Disallow empty strings as filenames
lukinovec May 1, 2026
1a01164
Make validateFilename accept string instead of ?string
lukinovec May 1, 2026
665404e
Add `DatabaseTenancyBootstrapper::$harden`
lukinovec May 1, 2026
fbd1e02
Correct DatabaseTenancyBootstrapper test filename
lukinovec May 1, 2026
fc6a931
Fix code style (php-cs-fixer)
github-actions[bot] May 1, 2026
f5f5f1d
Fix DB bootstrapper test
lukinovec May 1, 2026
52f6857
If harden throws an exception, revert connection back to central
lukinovec May 1, 2026
0ce3d86
DATABASE_URL test: set config for both datasets
lukinovec May 1, 2026
2ae1f79
Cover empty string parameters
lukinovec May 1, 2026
b1f0d0a
Get central DB from config in harden test
lukinovec May 1, 2026
7363318
Make in-memory DB detection more strict
lukinovec May 1, 2026
48b4837
Validate in-memory db names, move SQLite-specific methods to the SQLi…
lukinovec May 1, 2026
7683bef
Fix code style (php-cs-fixer)
github-actions[bot] May 1, 2026
e48d822
Validate SQLite DB name unconditionally in getPath()
lukinovec May 1, 2026
9a9adc0
Use getPath() in makeConnectionConfig()
lukinovec May 1, 2026
7f93f44
Test that the SQLite DB manager recognizes in-memory DBs
lukinovec May 1, 2026
7660ddd
Improve readability of harden() call
lukinovec May 1, 2026
26c161a
Add regression test for makeConnectionConfig not working correctly wi…
lukinovec May 1, 2026
429e098
Improve code quality and comments
lukinovec May 1, 2026
ea20eb1
Validate in-memory DBs outside of isInMemory
lukinovec May 1, 2026
405aaaf
Handle MySQL charset and collation
lukinovec May 4, 2026
2b3466f
Check the current DB name instead of configured one in harden()
lukinovec May 4, 2026
338526d
Query for MySQL defaults instead of assuming them in charset test
lukinovec May 4, 2026
fec170a
Fix code style (php-cs-fixer)
github-actions[bot] May 4, 2026
98a808b
Quote schema names in GRANT statements
lukinovec May 4, 2026
6ed9975
Catch broader range of exceptions (harden() in DB bootstrapper)
lukinovec May 4, 2026
de91348
Specify exception message in assertions
lukinovec May 4, 2026
bdbfbd4
Remove extra variable
lukinovec May 4, 2026
e59195e
Improve coverage
lukinovec May 4, 2026
66ae88a
Fix non-string parameter validation assertion
lukinovec May 4, 2026
0331875
Specify charset and collation config in test
lukinovec May 4, 2026
bbd8f6f
Add parentheses to instanceof check
lukinovec May 4, 2026
587f347
Restore default charset after assertion
lukinovec May 4, 2026
099a666
Add valid password assertion
lukinovec May 4, 2026
649c802
Use unique DB names and passwords in test
lukinovec May 4, 2026
519c819
Delete user created in validation test
lukinovec May 5, 2026
d9ae274
Delete redundant cleanup
lukinovec May 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/Bootstrappers/DatabaseTenancyBootstrapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,24 @@
namespace Stancl\Tenancy\Bootstrappers;

use Exception;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use RuntimeException;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Database\Contracts\TenantWithDatabase;
use Stancl\Tenancy\Database\DatabaseManager;
use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException;
use Throwable;

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;

Expand Down Expand Up @@ -41,10 +51,38 @@ public function bootstrap(Tenant $tenant): void
}

$this->database->connectToTenant($tenant);

if (static::$harden) {
try {
$this->harden($tenant);
} catch (Throwable $e) {
// Revert connection back to central
$this->revert();

throw $e;
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

public function revert(): void
{
$this->database->reconnectToCentral();
}

protected function harden(Tenant $tenant): void
{
$dbName = DB::getDatabaseName();

// Check if any other tenant uses this tenant's database
if ($tenant::where($tenant->getTenantKeyName(), '!=', $tenant->getTenantKey())
->where('data->tenancy_db_name', $dbName)
->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.');
}
}
}
7 changes: 6 additions & 1 deletion src/Database/Concerns/ManagesPostgresUsers.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ public function createUser(DatabaseConfig $databaseConfig): bool
$username = $databaseConfig->getUsername();
$password = $databaseConfig->getPassword();

$this->validateParameter($username);
$this->validatePassword($password);

$createUser = ! $this->userExists($username);

if ($createUser) {
Expand All @@ -44,6 +47,8 @@ public function deleteUser(DatabaseConfig $databaseConfig): bool
// Tenant DB username
$username = $databaseConfig->getUsername();

$this->validateParameter($username);

// Tenant host connection config
$connectionName = $this->connection()->getConfig('name');
$centralDatabase = $this->connection()->getConfig('database');
Expand Down Expand Up @@ -77,6 +82,6 @@ public function deleteUser(DatabaseConfig $databaseConfig): bool

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]);
}
}
93 changes: 93 additions & 0 deletions src/Database/Concerns/ValidatesDatabaseParameters.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<?php

declare(strict_types=1);

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 cannot be used.
*
* @mixin \Stancl\Tenancy\Database\TenantDatabaseManagers\TenantDatabaseManager
* @mixin \Stancl\Tenancy\Database\TenantDatabaseManagers\SQLiteDatabaseManager
*/
trait ValidatesDatabaseParameters
{
/**
* Characters allowed in parameters.
*
* Used as the default allowlist in validateParameter(), which validates non-password
* parameters such as database names or usernames.
*/
protected function allowedParameterCharacters(): string
{
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 function allowedPasswordCharacters(): string
{
return ' !#$%&()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]^_abcdefghijklmnopqrstuvwxyz{|}~';
}

/**
* Ensure that parameters (database names, usernames, etc.)
* only contain allowed characters before used in SQL statements
* (or paths in the case of SQLiteDatabaseManager).
*
* By default, only the characters in allowedParameterCharacters() are allowed.
*
* Null parameters are skipped.
*
* @throws InvalidArgumentException
*/
protected function validateParameter(string|array|null $parameters, string|null $allowedCharacters = null): void
{
$allowedCharacters ??= $this->allowedParameterCharacters();

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 null and it gets passed).
continue;
}

if (! is_string($parameter)) {
// E.g. if a parameter is retrieved from the config, it isn't necessarily a string
throw new InvalidArgumentException('Parameter has to be a string.');
}

foreach (str_split($parameter) as $character) {
if (! str_contains($allowedCharacters, $character)) {
throw new InvalidArgumentException("Forbidden character '{$character}' in parameter.");
}
}
}
}

/**
* Ensure password only contains allowed characters (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.
*
* @throws InvalidArgumentException
*/
protected function validatePassword(string|null $password): void
{
$this->validateParameter($password, allowedCharacters: $this->allowedPasswordCharacters());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,22 @@ public function createDatabase(TenantWithDatabase $tenant): bool
{
$database = $tenant->database()->getName();

$this->validateParameter($database);

return $this->connection()->statement("CREATE DATABASE [{$database}]");
}

public function deleteDatabase(TenantWithDatabase $tenant): bool
{
return $this->connection()->statement("DROP DATABASE [{$tenant->database()->getName()}]");
$database = $tenant->database()->getName();

$this->validateParameter($database);

return $this->connection()->statement("DROP DATABASE [{$database}]");
}

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]);
}
}
26 changes: 23 additions & 3 deletions src/Database/TenantDatabaseManagers/MySQLDatabaseManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,36 @@ public function createDatabase(TenantWithDatabase $tenant): bool
$charset = $this->connection()->getConfig('charset');
$collation = $this->connection()->getConfig('collation');

return $this->connection()->statement("CREATE DATABASE `{$database}` CHARACTER SET `$charset` COLLATE `$collation`");
$this->validateParameter([$database, $charset, $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
{
return $this->connection()->statement("DROP DATABASE `{$tenant->database()->getName()}`");
$database = $tenant->database()->getName();

$this->validateParameter($database);

return $this->connection()->statement("DROP DATABASE `{$database}`");
}

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]);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ public function createUser(DatabaseConfig $databaseConfig): bool
$username = $databaseConfig->getUsername();
$password = $databaseConfig->getPassword();

$this->validateParameter([$database, $username]);
$this->validatePassword($password);

// Create login
$this->connection()->statement("CREATE LOGIN [$username] WITH PASSWORD = '$password'");

Expand All @@ -37,12 +40,16 @@ public function createUser(DatabaseConfig $databaseConfig): bool

public function deleteUser(DatabaseConfig $databaseConfig): bool
{
return $this->connection()->statement("DROP LOGIN [{$databaseConfig->getUsername()}]");
$username = $databaseConfig->getUsername();

$this->validateParameter($username);

return $this->connection()->statement("DROP LOGIN [{$username}]");
}

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
Expand All @@ -54,11 +61,15 @@ public function makeConnectionConfig(array $baseConfig, string $databaseName): a

public function deleteDatabase(TenantWithDatabase $tenant): bool
{
$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
// 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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ public function createUser(DatabaseConfig $databaseConfig): bool
$username = $databaseConfig->getUsername();
$password = $databaseConfig->getPassword();

$this->validateParameter([$database, $username]);
$this->validatePassword($password);

$this->connection()->statement("CREATE USER `{$username}`@`%` IDENTIFIED BY '{$password}'");

$grants = implode(', ', static::$grants);
Expand All @@ -48,11 +51,15 @@ protected function isVersion8(): bool

public function deleteUser(DatabaseConfig $databaseConfig): bool
{
return $this->connection()->statement("DROP USER IF EXISTS '{$databaseConfig->getUsername()}'");
$username = $databaseConfig->getUsername();

$this->validateParameter($username);

return $this->connection()->statement("DROP USER IF EXISTS '{$username}'");
}

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(*)'};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ protected function grantPermissions(DatabaseConfig $databaseConfig): bool
$username = $databaseConfig->getUsername();
$schema = $databaseConfig->connection()['search_path'];

$this->validateParameter([$database, $username, $schema]);

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Host config
$connectionName = $this->connection()->getConfig('name');
$centralDatabase = $this->connection()->getConfig('database');
Expand All @@ -32,10 +34,10 @@ protected function grantPermissions(DatabaseConfig $databaseConfig): bool
$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]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,23 +23,25 @@ protected function grantPermissions(DatabaseConfig $databaseConfig): bool
// Central database name
$database = DB::connection(config('tenancy.database.central_connection'))->getDatabaseName();

$this->connection()->statement("GRANT CONNECT ON DATABASE {$database} TO \"{$username}\"");
$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}\"");

$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) {
$tableName = $table->table_name;

/** @var string $primaryKey */
$primaryKey = $this->connection()->selectOne(<<<SQL
$primaryKey = $this->connection()->selectOne(<<<'SQL'
SELECT column_name
FROM information_schema.key_column_usage
WHERE table_name = '{$tableName}'
WHERE table_name = ?
AND constraint_name LIKE '%_pkey'
SQL)->column_name;
SQL, [$tableName])->column_name;
Comment thread
lukinovec marked this conversation as resolved.

Comment thread
lukinovec marked this conversation as resolved.
// Grant all permissions for all existing tables
$this->connection()->statement("GRANT ALL ON \"{$schema}\".\"{$tableName}\" TO \"{$username}\"");
Expand Down
Loading
Loading