mirror of
https://github.com/archtechx/tenancy.git
synced 2026-05-06 20:34:04 +00:00
Merge d9ae27425d into 23b18c93a0
This commit is contained in:
commit
9ed7f1abf9
16 changed files with 609 additions and 61 deletions
|
|
@ -28,6 +28,9 @@ trait ManagesPostgresUsers
|
|||
$username = $databaseConfig->getUsername();
|
||||
$password = $databaseConfig->getPassword();
|
||||
|
||||
$this->validateParameter($username);
|
||||
$this->validatePassword($password);
|
||||
|
||||
$createUser = ! $this->userExists($username);
|
||||
|
||||
if ($createUser) {
|
||||
|
|
@ -44,6 +47,8 @@ trait ManagesPostgresUsers
|
|||
// Tenant DB username
|
||||
$username = $databaseConfig->getUsername();
|
||||
|
||||
$this->validateParameter($username);
|
||||
|
||||
// Tenant host connection config
|
||||
$connectionName = $this->connection()->getConfig('name');
|
||||
$centralDatabase = $this->connection()->getConfig('database');
|
||||
|
|
@ -77,6 +82,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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
93
src/Database/Concerns/ValidatesDatabaseParameters.php
Normal file
93
src/Database/Concerns/ValidatesDatabaseParameters.php
Normal file
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -12,16 +12,22 @@ class MicrosoftSQLDatabaseManager extends TenantDatabaseManager
|
|||
{
|
||||
$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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,16 +14,36 @@ class MySQLDatabaseManager extends TenantDatabaseManager
|
|||
$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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ class PermissionControlledMicrosoftSQLServerDatabaseManager extends MicrosoftSQL
|
|||
$username = $databaseConfig->getUsername();
|
||||
$password = $databaseConfig->getPassword();
|
||||
|
||||
$this->validateParameter([$database, $username]);
|
||||
$this->validatePassword($password);
|
||||
|
||||
// Create login
|
||||
$this->connection()->statement("CREATE LOGIN [$username] WITH PASSWORD = '$password'");
|
||||
|
||||
|
|
@ -37,12 +40,16 @@ class PermissionControlledMicrosoftSQLServerDatabaseManager extends MicrosoftSQL
|
|||
|
||||
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
|
||||
|
|
@ -54,11 +61,15 @@ class PermissionControlledMicrosoftSQLServerDatabaseManager extends MicrosoftSQL
|
|||
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ class PermissionControlledMySQLDatabaseManager extends MySQLDatabaseManager impl
|
|||
$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);
|
||||
|
|
@ -48,11 +51,15 @@ class PermissionControlledMySQLDatabaseManager extends MySQLDatabaseManager impl
|
|||
|
||||
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(*)'};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
@ -32,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]);
|
||||
|
|
|
|||
|
|
@ -23,23 +23,25 @@ class PermissionControlledPostgreSQLSchemaManager extends PostgreSQLSchemaManage
|
|||
// 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;
|
||||
|
||||
// Grant all permissions for all existing tables
|
||||
$this->connection()->statement("GRANT ALL ON \"{$schema}\".\"{$tableName}\" TO \"{$username}\"");
|
||||
|
|
|
|||
|
|
@ -10,16 +10,24 @@ class PostgreSQLDatabaseManager extends TenantDatabaseManager
|
|||
{
|
||||
public function createDatabase(TenantWithDatabase $tenant): bool
|
||||
{
|
||||
return $this->connection()->statement("CREATE DATABASE \"{$tenant->database()->getName()}\" WITH TEMPLATE=template0");
|
||||
$name = $tenant->database()->getName();
|
||||
|
||||
$this->validateParameter($name);
|
||||
|
||||
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 = $tenant->database()->getName();
|
||||
|
||||
$this->validateParameter($name);
|
||||
|
||||
return $this->connection()->statement("DROP DATABASE \"{$name}\"");
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,17 +10,25 @@ class PostgreSQLSchemaManager extends TenantDatabaseManager
|
|||
{
|
||||
public function createDatabase(TenantWithDatabase $tenant): bool
|
||||
{
|
||||
return $this->connection()->statement("CREATE SCHEMA \"{$tenant->database()->getName()}\"");
|
||||
$name = $tenant->database()->getName();
|
||||
|
||||
$this->validateParameter($name);
|
||||
|
||||
return $this->connection()->statement("CREATE SCHEMA \"{$name}\"");
|
||||
}
|
||||
|
||||
public function deleteDatabase(TenantWithDatabase $tenant): bool
|
||||
{
|
||||
return $this->connection()->statement("DROP SCHEMA \"{$tenant->database()->getName()}\" CASCADE");
|
||||
$name = $tenant->database()->getName();
|
||||
|
||||
$this->validateParameter($name);
|
||||
|
||||
return $this->connection()->statement("DROP SCHEMA \"{$name}\" CASCADE");
|
||||
}
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -6,13 +6,17 @@ 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;
|
||||
|
||||
class SQLiteDatabaseManager implements TenantDatabaseManager
|
||||
{
|
||||
use ValidatesDatabaseParameters;
|
||||
|
||||
/**
|
||||
* SQLite database directory path.
|
||||
*
|
||||
|
|
@ -57,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 */
|
||||
|
|
@ -122,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) {
|
||||
|
|
@ -129,7 +146,7 @@ class SQLiteDatabaseManager implements TenantDatabaseManager
|
|||
(static::$persistInMemoryConnectionUsing)(new PDO($dsn), $dsn);
|
||||
}
|
||||
} else {
|
||||
$baseConfig['database'] = database_path($databaseName);
|
||||
$baseConfig['database'] = $this->getPath($databaseName);
|
||||
}
|
||||
|
||||
return $baseConfig;
|
||||
|
|
@ -137,6 +154,8 @@ 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;
|
||||
}
|
||||
|
|
@ -146,6 +165,28 @@ class SQLiteDatabaseManager implements TenantDatabaseManager
|
|||
|
||||
public static function isInMemory(string $name): bool
|
||||
{
|
||||
return $name === ':memory:' || str_contains($name, '_tenancy_inmemory_');
|
||||
$isNamed = str_starts_with($name, 'file:_tenancy_inmemory_') &&
|
||||
str_ends_with($name, '?mode=memory&cache=shared');
|
||||
|
||||
return $name === ':memory:' || $isNamed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure database name only contains allowed characters
|
||||
* (allowedDatabaseNameCharacters() + $extraAllowedCharacters) and is not a directory name.
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
protected function validateDatabaseName(string $name, string $extraAllowedCharacters = ''): void
|
||||
{
|
||||
$this->validateParameter($name, $this->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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,11 +6,14 @@ namespace Stancl\Tenancy\Database\TenantDatabaseManagers;
|
|||
|
||||
use Illuminate\Database\Connection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Stancl\Tenancy\Database\Concerns\ValidatesDatabaseParameters;
|
||||
use Stancl\Tenancy\Database\Contracts\StatefulTenantDatabaseManager;
|
||||
use Stancl\Tenancy\Database\Exceptions\NoConnectionSetException;
|
||||
|
||||
abstract class TenantDatabaseManager implements StatefulTenantDatabaseManager
|
||||
{
|
||||
use ValidatesDatabaseParameters;
|
||||
|
||||
/** The database connection to the server. */
|
||||
protected string $connection;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue