mirror of
https://github.com/archtechx/tenancy.git
synced 2025-12-12 17:24:03 +00:00
Postgres RLS + permission controlled database managers (#33)
This PR adds Postgres RLS (trait manager + table manager approach) and permission controlled managers for PostgreSQL. --------- Co-authored-by: lukinovec <lukinovec@gmail.com> Co-authored-by: PHP CS Fixer <phpcsfixer@example.com>
This commit is contained in:
parent
34297d3e1a
commit
7317d2638a
39 changed files with 2511 additions and 112 deletions
63
src/Bootstrappers/PostgresRLSBootstrapper.php
Normal file
63
src/Bootstrappers/PostgresRLSBootstrapper.php
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Stancl\Tenancy\Bootstrappers;
|
||||
|
||||
use Illuminate\Contracts\Config\Repository;
|
||||
use Illuminate\Database\DatabaseManager;
|
||||
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
|
||||
use Stancl\Tenancy\Contracts\Tenant;
|
||||
use Stancl\Tenancy\Database\DatabaseManager as TenantConnectionManager;
|
||||
|
||||
/**
|
||||
* When initializing tenancy, use the tenant connection with the configured RLS user credentials
|
||||
* and set the configured session variable to the current tenant's key.
|
||||
*
|
||||
* When ending tenancy, reset the session variable (to invalidate the connection)
|
||||
* and switch back to the central connection again.
|
||||
*
|
||||
* This bootstrapper is intended to be used with Postgres RLS.
|
||||
*
|
||||
* @see \Stancl\Tenancy\Commands\CreateUserWithRLSPolicies
|
||||
*/
|
||||
class PostgresRLSBootstrapper implements TenancyBootstrapper
|
||||
{
|
||||
public function __construct(
|
||||
protected Repository $config,
|
||||
protected DatabaseManager $database,
|
||||
protected TenantConnectionManager $tenantConnectionManager,
|
||||
) {}
|
||||
|
||||
public function bootstrap(Tenant $tenant): void
|
||||
{
|
||||
$this->connectToTenant();
|
||||
|
||||
$tenantSessionKey = $this->config->get('tenancy.rls.session_variable_name');
|
||||
|
||||
$this->database->statement("SET {$tenantSessionKey} = '{$tenant->getTenantKey()}'");
|
||||
}
|
||||
|
||||
public function revert(): void
|
||||
{
|
||||
$this->database->statement("RESET {$this->config->get('tenancy.rls.session_variable_name')}");
|
||||
|
||||
$this->tenantConnectionManager->reconnectToCentral();
|
||||
}
|
||||
|
||||
protected function connectToTenant(): void
|
||||
{
|
||||
$centralConnection = $this->config->get('tenancy.database.central_connection');
|
||||
|
||||
$this->tenantConnectionManager->purgeTenantConnection();
|
||||
|
||||
$tenantConnection = array_merge($this->config->get('database.connections.' . $centralConnection), [
|
||||
'username' => $this->config->get('tenancy.rls.user.username'),
|
||||
'password' => $this->config->get('tenancy.rls.user.password'),
|
||||
]);
|
||||
|
||||
$this->config['database.connections.tenant'] = $tenantConnection;
|
||||
|
||||
$this->tenantConnectionManager->setDefaultConnection('tenant');
|
||||
}
|
||||
}
|
||||
|
|
@ -105,7 +105,7 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
|
|||
if (tenancy()->initialized) {
|
||||
// Tenancy is already initialized
|
||||
if (tenant()->getTenantKey() === $tenantId) {
|
||||
// It's initialized for the same tenant (e.g. dispatchNow was used, or the previous job also ran for this tenant)
|
||||
// It's initialized for the same tenant (e.g. dispatchSync was used, or the previous job also ran for this tenant)
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
211
src/Commands/CreateUserWithRLSPolicies.php
Normal file
211
src/Commands/CreateUserWithRLSPolicies.php
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Stancl\Tenancy\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Stancl\Tenancy\Database\Contracts\TenantWithDatabase;
|
||||
use Stancl\Tenancy\Database\DatabaseConfig;
|
||||
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQLSchemaManager;
|
||||
use Stancl\Tenancy\RLS\PolicyManagers\RLSPolicyManager;
|
||||
|
||||
/**
|
||||
* Creates RLS policies for tables of models related to the tenants table.
|
||||
*
|
||||
* This command is used with Postgres + single-database tenancy, specifically when using RLS.
|
||||
*/
|
||||
class CreateUserWithRLSPolicies extends Command
|
||||
{
|
||||
protected $signature = 'tenants:rls {--force= : Create RLS policies even if they already exist.}';
|
||||
|
||||
protected $description = "Creates RLS policies for all tables related to the tenant table. Also creates the RLS user if it doesn't exist yet";
|
||||
|
||||
public function handle(PermissionControlledPostgreSQLSchemaManager $manager): int
|
||||
{
|
||||
$username = config('tenancy.rls.user.username');
|
||||
$password = config('tenancy.rls.user.password');
|
||||
|
||||
if ($username === null || $password === null) {
|
||||
$this->components->error('The RLS user credentials are not set in the "tenancy.rls.user" config.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$this->components->info(
|
||||
$manager->createUser($this->makeDatabaseConfig($manager, $username, $password))
|
||||
? "RLS user '{$username}' has been created."
|
||||
: "RLS user '{$username}' already exists."
|
||||
);
|
||||
|
||||
$this->createTablePolicies();
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
protected function enableRLS(string $table): void
|
||||
{
|
||||
// Enable RLS scoping on the table (without this, queries won't be scoped using RLS)
|
||||
DB::statement("ALTER TABLE {$table} ENABLE ROW LEVEL SECURITY");
|
||||
|
||||
/**
|
||||
* Force RLS scoping on the table, so that the table owner users
|
||||
* don't bypass the scoping – table owners bypass RLS by default.
|
||||
*
|
||||
* E.g. when using a custom implementation where you create tables as the RLS user,
|
||||
* the queries won't be scoped for the RLS user unless we force the RLS scoping using this query.
|
||||
*/
|
||||
DB::statement("ALTER TABLE {$table} FORCE ROW LEVEL SECURITY");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a DatabaseConfig instance for the RLS user,
|
||||
* so that the user can get created using $manager->createUser($databaseConfig).
|
||||
*/
|
||||
protected function makeDatabaseConfig(
|
||||
PermissionControlledPostgreSQLSchemaManager $manager,
|
||||
string $username,
|
||||
string $password,
|
||||
): DatabaseConfig {
|
||||
/** @var TenantWithDatabase $tenantModel */
|
||||
$tenantModel = tenancy()->model();
|
||||
|
||||
// Use a temporary DatabaseConfig instance to set the host connection
|
||||
$temporaryDbConfig = $tenantModel->database();
|
||||
|
||||
$temporaryDbConfig->purgeHostConnection();
|
||||
|
||||
$tenantHostConnectionName = $temporaryDbConfig->getTenantHostConnectionName();
|
||||
config(["database.connections.{$tenantHostConnectionName}" => $temporaryDbConfig->hostConnection()]);
|
||||
|
||||
// Use the tenant host connection in the manager
|
||||
$manager->setConnection($tenantModel->database()->getTenantHostConnectionName());
|
||||
|
||||
// Set the database name (= central schema name/search_path in this case), username, and password
|
||||
$tenantModel->setInternal('db_name', $manager->database()->getConfig('search_path'));
|
||||
$tenantModel->setInternal('db_username', $username);
|
||||
$tenantModel->setInternal('db_password', $password);
|
||||
|
||||
return $tenantModel->database();
|
||||
}
|
||||
|
||||
protected function createTablePolicies(): void
|
||||
{
|
||||
/** @var RLSPolicyManager $rlsPolicyManager */
|
||||
$rlsPolicyManager = app(config('tenancy.rls.manager'));
|
||||
$rlsQueries = $rlsPolicyManager->generateQueries();
|
||||
|
||||
$zombiePolicies = $this->dropZombiePolicies(array_keys($rlsQueries));
|
||||
|
||||
if ($zombiePolicies > 0) {
|
||||
$this->components->warn("Dropped {$zombiePolicies} zombie RLS policies.");
|
||||
}
|
||||
|
||||
$createdPolicies = [];
|
||||
|
||||
foreach ($rlsQueries as $table => $query) {
|
||||
[$hash, $policyQuery] = $this->hashPolicy($query);
|
||||
$expectedName = $table . '_rls_policy_' . $hash;
|
||||
|
||||
$tableRLSPolicy = $this->findTableRLSPolicy($table);
|
||||
$olderPolicyExists = $tableRLSPolicy && $tableRLSPolicy->policyname !== $expectedName;
|
||||
|
||||
// Drop the policy if an outdated version exists
|
||||
// or if it exists (even in the current form) and the --force option is used
|
||||
$dropPolicy = $olderPolicyExists || ($tableRLSPolicy && $this->option('force'));
|
||||
|
||||
if ($tableRLSPolicy && $dropPolicy) {
|
||||
DB::statement("DROP POLICY {$tableRLSPolicy->policyname} ON {$table}");
|
||||
|
||||
$this->components->info("RLS policy for table '{$table}' dropped.");
|
||||
}
|
||||
|
||||
// Create RLS policy if the table doesn't have it or if the --force option is used
|
||||
$createPolicy = $dropPolicy || ! $tableRLSPolicy || $this->option('force');
|
||||
|
||||
if ($createPolicy) {
|
||||
DB::statement($policyQuery);
|
||||
|
||||
$this->enableRLS($table);
|
||||
|
||||
$createdPolicies[] = $table . " ($hash)";
|
||||
} else {
|
||||
$this->components->info("Table '{$table}' already has an up to date RLS policy.");
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($createdPolicies)) {
|
||||
$managerName = str($rlsPolicyManager::class)->afterLast('\\')->toString();
|
||||
|
||||
$this->components->info("RLS policies created for tables (using {$managerName}):");
|
||||
|
||||
$this->components->bulletList($createdPolicies);
|
||||
|
||||
$this->components->info('RLS policies updated successfully.');
|
||||
} else {
|
||||
$this->components->info('All RLS policies are up to date.');
|
||||
}
|
||||
}
|
||||
|
||||
/** @return \stdClass|null */
|
||||
protected function findTableRLSPolicy(string $table): object|null
|
||||
{
|
||||
return DB::selectOne(<<<SQL
|
||||
SELECT * FROM pg_policies
|
||||
WHERE tablename = '{$table}'
|
||||
AND policyname LIKE '{$table}_rls_policy%';
|
||||
SQL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a raw RLS policy query into a "versioned" query
|
||||
* where the policy name is suffixed with a hash of the policy body.
|
||||
*
|
||||
* Returns the hash and the versioned query as a tuple.
|
||||
*
|
||||
* @return array{string, string}
|
||||
*/
|
||||
public function hashPolicy(string $query): array
|
||||
{
|
||||
$lines = explode("\n", $query);
|
||||
|
||||
// We split the query into the first line, the last line, and the actual body in between
|
||||
$firstLine = array_shift($lines);
|
||||
$lastLine = array_pop($lines);
|
||||
$body = implode("\n", $lines);
|
||||
|
||||
// We update the policy name on the first line to contain a hash of the policy body
|
||||
// to keep track of the version of the policy
|
||||
$hash = substr(sha1($body), 0, 6);
|
||||
$firstLine = str_replace('_policy ON ', "_policy_{$hash} ON ", $firstLine);
|
||||
$policyQuery = $firstLine . "\n" . $body . "\n" . $lastLine;
|
||||
|
||||
return [$hash, $policyQuery];
|
||||
}
|
||||
|
||||
/**
|
||||
* Here we handle an edge case where a table may have an existing RLS policy
|
||||
* but is not included in $rlsQueries, this can happen e.g. when changing $scopeByDefault.
|
||||
* For these tables -- that have an existing policy but now SHOULDN'T have one -- we drop
|
||||
* the existing policies.
|
||||
*/
|
||||
protected function dropZombiePolicies(array $tablesThatShouldHavePolicies): int
|
||||
{
|
||||
/** @var \stdClass[] $tablesWithRLSPolicies */
|
||||
$tablesWithRLSPolicies = DB::select("SELECT tablename, policyname FROM pg_policies WHERE policyname LIKE '%_rls_policy%'");
|
||||
|
||||
$zombies = 0;
|
||||
|
||||
foreach ($tablesWithRLSPolicies as $table) {
|
||||
if (! in_array($table->tablename, $tablesThatShouldHavePolicies, true)) {
|
||||
DB::statement("DROP POLICY {$table->policyname} ON {$table->tablename}");
|
||||
|
||||
$this->components->warn("RLS policy for table '{$table->tablename}' dropped (zombie).");
|
||||
$zombies++;
|
||||
}
|
||||
}
|
||||
|
||||
return $zombies;
|
||||
}
|
||||
}
|
||||
34
src/Concerns/ManagesRLSPolicies.php
Normal file
34
src/Concerns/ManagesRLSPolicies.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Stancl\Tenancy\Concerns;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use stdClass;
|
||||
|
||||
/**
|
||||
* Used for easily dropping RLS policies on tables, primarily in migrations.
|
||||
*/
|
||||
trait ManagesRLSPolicies
|
||||
{
|
||||
/** @return string[] */
|
||||
public static function getRLSPolicies(string $table): array
|
||||
{
|
||||
return array_map(
|
||||
fn (stdClass $policy) => $policy->policyname,
|
||||
DB::select("SELECT policyname FROM pg_policies WHERE tablename = '{$table}' AND policyname LIKE '%_rls_policy%'")
|
||||
);
|
||||
}
|
||||
|
||||
public static function dropRLSPolicies(string $table): int
|
||||
{
|
||||
$policies = static::getRLSPolicies($table);
|
||||
|
||||
foreach ($policies as $policy) {
|
||||
DB::statement('DROP POLICY ? ON ?', [$policy, $table]);
|
||||
}
|
||||
|
||||
return count($policies);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||
namespace Stancl\Tenancy\Database\Concerns;
|
||||
|
||||
use Stancl\Tenancy\Database\ParentModelScope;
|
||||
use Stancl\Tenancy\RLS\PolicyManagers\TraitRLSManager;
|
||||
|
||||
trait BelongsToPrimaryModel
|
||||
{
|
||||
|
|
@ -12,6 +13,10 @@ trait BelongsToPrimaryModel
|
|||
|
||||
public static function bootBelongsToPrimaryModel(): void
|
||||
{
|
||||
static::addGlobalScope(new ParentModelScope);
|
||||
$implicitRLS = config('tenancy.rls.manager') === TraitRLSManager::class && TraitRLSManager::$implicitRLS;
|
||||
|
||||
if (! $implicitRLS && ! (new static) instanceof RLSModel) {
|
||||
static::addGlobalScope(new ParentModelScope);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ namespace Stancl\Tenancy\Database\Concerns;
|
|||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Stancl\Tenancy\Contracts\Tenant;
|
||||
use Stancl\Tenancy\Database\TenantScope;
|
||||
use Stancl\Tenancy\RLS\PolicyManagers\TraitRLSManager;
|
||||
use Stancl\Tenancy\Tenancy;
|
||||
|
||||
/**
|
||||
|
|
@ -14,6 +15,8 @@ use Stancl\Tenancy\Tenancy;
|
|||
*/
|
||||
trait BelongsToTenant
|
||||
{
|
||||
use FillsCurrentTenant;
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(config('tenancy.models.tenant'), Tenancy::tenantKeyColumn());
|
||||
|
|
@ -21,15 +24,12 @@ trait BelongsToTenant
|
|||
|
||||
public static function bootBelongsToTenant(): void
|
||||
{
|
||||
static::addGlobalScope(new TenantScope);
|
||||
// If TraitRLSManager::$implicitRLS is true or this model implements RLSModel
|
||||
// Postgres RLS is used for scoping, so we don't enable the scope used with single-database tenancy.
|
||||
$implicitRLS = config('tenancy.rls.manager') === TraitRLSManager::class && TraitRLSManager::$implicitRLS;
|
||||
|
||||
static::creating(function ($model) {
|
||||
if (! $model->getAttribute(Tenancy::tenantKeyColumn()) && ! $model->relationLoaded('tenant')) {
|
||||
if (tenancy()->initialized) {
|
||||
$model->setAttribute(Tenancy::tenantKeyColumn(), tenant()->getTenantKey());
|
||||
$model->setRelation('tenant', tenant());
|
||||
}
|
||||
}
|
||||
});
|
||||
if (! $implicitRLS && ! (new static) instanceof RLSModel) {
|
||||
static::addGlobalScope(new TenantScope);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,8 +17,9 @@ trait CreatesDatabaseUsers
|
|||
|
||||
public function deleteDatabase(TenantWithDatabase $tenant): bool
|
||||
{
|
||||
parent::deleteDatabase($tenant);
|
||||
// Some DB engines require the user to be deleted before the database (e.g. Postgres)
|
||||
$this->deleteUser($tenant->database());
|
||||
|
||||
return $this->deleteUser($tenant->database());
|
||||
return parent::deleteDatabase($tenant);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
22
src/Database/Concerns/FillsCurrentTenant.php
Normal file
22
src/Database/Concerns/FillsCurrentTenant.php
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Stancl\Tenancy\Database\Concerns;
|
||||
|
||||
use Stancl\Tenancy\Tenancy;
|
||||
|
||||
trait FillsCurrentTenant
|
||||
{
|
||||
public static function bootFillsCurrentTenant(): void
|
||||
{
|
||||
static::creating(function ($model) {
|
||||
if (! $model->getAttribute(Tenancy::tenantKeyColumn()) && ! $model->relationLoaded('tenant')) {
|
||||
if (tenancy()->initialized) {
|
||||
$model->setAttribute(Tenancy::tenantKeyColumn(), tenant()->getTenantKey());
|
||||
$model->setRelation('tenant', tenant());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
83
src/Database/Concerns/ManagesPostgresUsers.php
Normal file
83
src/Database/Concerns/ManagesPostgresUsers.php
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Stancl\Tenancy\Database\Concerns;
|
||||
|
||||
use Stancl\Tenancy\Database\DatabaseConfig;
|
||||
use Stancl\Tenancy\Database\TenantDatabaseManagers\TenantDatabaseManager;
|
||||
|
||||
/**
|
||||
* @method \Illuminate\Database\Connection database()
|
||||
* @mixin TenantDatabaseManager
|
||||
*/
|
||||
trait ManagesPostgresUsers
|
||||
{
|
||||
/**
|
||||
* Grant database/schema and table permissions to the user whose credentials are stored in the passed DatabaseConfig.
|
||||
*
|
||||
* With schema manager, the schema name is stored in the 'search_path' key of the connection config,
|
||||
* but it's still accessible using $databaseConfig->getName().
|
||||
*/
|
||||
abstract protected function grantPermissions(DatabaseConfig $databaseConfig): bool;
|
||||
|
||||
public function createUser(DatabaseConfig $databaseConfig): bool
|
||||
{
|
||||
/** @var string $username */
|
||||
$username = $databaseConfig->getUsername();
|
||||
$password = $databaseConfig->getPassword();
|
||||
|
||||
$createUser = ! $this->userExists($username);
|
||||
|
||||
if ($createUser) {
|
||||
$this->database()->statement("CREATE USER \"{$username}\" LOGIN PASSWORD '{$password}'");
|
||||
}
|
||||
|
||||
$this->grantPermissions($databaseConfig);
|
||||
|
||||
return $createUser;
|
||||
}
|
||||
|
||||
public function deleteUser(DatabaseConfig $databaseConfig): bool
|
||||
{
|
||||
/** @var TenantDatabaseManager $this */
|
||||
|
||||
// Tenant DB username
|
||||
$username = $databaseConfig->getUsername();
|
||||
|
||||
// Tenant host connection config
|
||||
$connectionName = $this->database()->getConfig('name');
|
||||
$centralDatabase = $this->database()->getConfig('database');
|
||||
|
||||
// Set the DB/schema name to the tenant DB/schema name
|
||||
config()->set(
|
||||
"database.connections.{$connectionName}",
|
||||
$this->makeConnectionConfig($this->database()->getConfig(), $databaseConfig->getName()),
|
||||
);
|
||||
|
||||
// Connect to the tenant DB/schema
|
||||
$this->database()->reconnect();
|
||||
|
||||
// Delete all database objects owned by the user (privileges, tables, views, etc.)
|
||||
// Postgres users cannot be deleted unless we delete all objects owned by it first
|
||||
$this->database()->statement("DROP OWNED BY \"{$username}\"");
|
||||
|
||||
// Delete the user
|
||||
$userDeleted = $this->database()->statement("DROP USER \"{$username}\"");
|
||||
|
||||
config()->set(
|
||||
"database.connections.{$connectionName}",
|
||||
$this->makeConnectionConfig($this->database()->getConfig(), $centralDatabase),
|
||||
);
|
||||
|
||||
// Reconnect to the central database
|
||||
$this->database()->reconnect();
|
||||
|
||||
return $userDeleted;
|
||||
}
|
||||
|
||||
public function userExists(string $username): bool
|
||||
{
|
||||
return (bool) $this->database()->selectOne("SELECT usename FROM pg_user WHERE usename = '{$username}'");
|
||||
}
|
||||
}
|
||||
21
src/Database/Concerns/RLSModel.php
Normal file
21
src/Database/Concerns/RLSModel.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Stancl\Tenancy\Database\Concerns;
|
||||
|
||||
/**
|
||||
* Interface indicating that the queries of the model it's used on
|
||||
* get scoped using RLS (instead of the global TenantScope).
|
||||
*
|
||||
* All models whose queries you want to scope using RLS
|
||||
* need to implement this interface if RLS scoping is explicit (= when TraitRLSManager::$implicitRLS is false).
|
||||
* The models also have to use one of the single-database traits.
|
||||
*
|
||||
* Used with Postgres RLS via TraitRLSManager.
|
||||
*
|
||||
* @see \Stancl\Tenancy\RLS\PolicyManagers\TraitRLSManager
|
||||
* @see BelongsToTenant
|
||||
* @see BelongsToPrimaryModel
|
||||
*/
|
||||
interface RLSModel {}
|
||||
18
src/Database/Exceptions/RecursiveRelationshipException.php
Normal file
18
src/Database/Exceptions/RecursiveRelationshipException.php
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Stancl\Tenancy\Database\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* @see \Stancl\Tenancy\RLS\PolicyManagers\TableRLSManager
|
||||
*/
|
||||
class RecursiveRelationshipException extends Exception
|
||||
{
|
||||
public function __construct(string|null $message = null)
|
||||
{
|
||||
parent::__construct($message ?? "Table's foreign key referenced multiple times in the same path.");
|
||||
}
|
||||
}
|
||||
18
src/Database/Exceptions/TableNotRelatedToTenantException.php
Normal file
18
src/Database/Exceptions/TableNotRelatedToTenantException.php
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Stancl\Tenancy\Database\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* @see \Stancl\Tenancy\RLS\PolicyManagers\TraitRLSManager
|
||||
*/
|
||||
class TableNotRelatedToTenantException extends Exception
|
||||
{
|
||||
public function __construct(string $table)
|
||||
{
|
||||
parent::__construct("Table $table does not belong to a tenant directly or through another table.");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Stancl\Tenancy\Database\TenantDatabaseManagers;
|
||||
|
||||
use Stancl\Tenancy\Database\Concerns\CreatesDatabaseUsers;
|
||||
use Stancl\Tenancy\Database\Concerns\ManagesPostgresUsers;
|
||||
use Stancl\Tenancy\Database\Contracts\ManagesDatabaseUsers;
|
||||
use Stancl\Tenancy\Database\DatabaseConfig;
|
||||
|
||||
class PermissionControlledPostgreSQLDatabaseManager extends PostgreSQLDatabaseManager implements ManagesDatabaseUsers
|
||||
{
|
||||
use CreatesDatabaseUsers, ManagesPostgresUsers;
|
||||
|
||||
protected function grantPermissions(DatabaseConfig $databaseConfig): bool
|
||||
{
|
||||
// Tenant DB config
|
||||
$database = $databaseConfig->getName();
|
||||
$username = $databaseConfig->getUsername();
|
||||
$schema = $databaseConfig->connection()['search_path'];
|
||||
|
||||
// Host config
|
||||
$connectionName = $this->database()->getConfig('name');
|
||||
$centralDatabase = $this->database()->getConfig('database');
|
||||
|
||||
$this->database()->statement("GRANT CONNECT ON DATABASE \"{$database}\" TO \"{$username}\"");
|
||||
|
||||
// Connect to tenant database
|
||||
config(["database.connections.{$connectionName}.database" => $database]);
|
||||
|
||||
$this->database()->reconnect();
|
||||
|
||||
// Grant permissions to create and use tables in the configured schema ("public" by default) to the user
|
||||
$this->database()->statement("GRANT USAGE, CREATE ON SCHEMA {$schema} TO \"{$username}\"");
|
||||
|
||||
// Grant permissions to use sequences in the current schema to the user
|
||||
$this->database()->statement("GRANT USAGE ON ALL SEQUENCES IN SCHEMA {$schema} TO \"{$username}\"");
|
||||
|
||||
// Reconnect to central database
|
||||
config(["database.connections.{$connectionName}.database" => $centralDatabase]);
|
||||
|
||||
$this->database()->reconnect();
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Stancl\Tenancy\Database\TenantDatabaseManagers;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Stancl\Tenancy\Database\Concerns\CreatesDatabaseUsers;
|
||||
use Stancl\Tenancy\Database\Concerns\ManagesPostgresUsers;
|
||||
use Stancl\Tenancy\Database\Contracts\ManagesDatabaseUsers;
|
||||
use Stancl\Tenancy\Database\DatabaseConfig;
|
||||
|
||||
class PermissionControlledPostgreSQLSchemaManager extends PostgreSQLSchemaManager implements ManagesDatabaseUsers
|
||||
{
|
||||
use CreatesDatabaseUsers, ManagesPostgresUsers;
|
||||
|
||||
protected function grantPermissions(DatabaseConfig $databaseConfig): bool
|
||||
{
|
||||
// Tenant DB config
|
||||
$username = $databaseConfig->getUsername();
|
||||
$schema = $databaseConfig->getName();
|
||||
|
||||
// Central database name
|
||||
$database = DB::connection(config('tenancy.database.central_connection'))->getDatabaseName();
|
||||
|
||||
$this->database()->statement("GRANT CONNECT ON DATABASE {$database} TO \"{$username}\"");
|
||||
$this->database()->statement("GRANT USAGE, CREATE ON SCHEMA \"{$schema}\" TO \"{$username}\"");
|
||||
$this->database()->statement("GRANT USAGE ON ALL SEQUENCES IN SCHEMA \"{$schema}\" TO \"{$username}\"");
|
||||
|
||||
$tables = $this->database()->select("SELECT table_name FROM information_schema.tables WHERE table_schema = '{$schema}'");
|
||||
|
||||
// Grant permissions to any existing tables. This is used with RLS
|
||||
// todo@samuel refactor this along with the todo in TenantDatabaseManager
|
||||
// and move the grantPermissions() call inside the condition in `ManagesPostgresUsers::createUser()`
|
||||
foreach ($tables as $table) {
|
||||
$tableName = $table->table_name;
|
||||
|
||||
/** @var string $primaryKey */
|
||||
$primaryKey = $this->database()->selectOne(<<<SQL
|
||||
SELECT column_name
|
||||
FROM information_schema.key_column_usage
|
||||
WHERE table_name = '{$tableName}'
|
||||
AND constraint_name LIKE '%_pkey'
|
||||
SQL)->column_name;
|
||||
|
||||
// Grant all permissions for all existing tables
|
||||
$this->database()->statement("GRANT ALL ON \"{$schema}\".\"{$tableName}\" TO \"{$username}\"");
|
||||
|
||||
// Grant permission to reference the primary key for the table
|
||||
// The previous query doesn't grant the references privilege, so it has to be granted here
|
||||
$this->database()->statement("GRANT REFERENCES (\"{$primaryKey}\") ON \"{$schema}\".\"{$tableName}\" TO \"{$username}\"");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,6 @@ class PostgreSQLDatabaseManager extends TenantDatabaseManager
|
|||
|
||||
public function databaseExists(string $name): bool
|
||||
{
|
||||
return (bool) $this->database()->select("SELECT datname FROM pg_database WHERE datname = '$name'");
|
||||
return (bool) $this->database()->selectOne("SELECT datname FROM pg_database WHERE datname = '$name'");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
15
src/RLS/PolicyManagers/RLSPolicyManager.php
Normal file
15
src/RLS/PolicyManagers/RLSPolicyManager.php
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Stancl\Tenancy\RLS\PolicyManagers;
|
||||
|
||||
interface RLSPolicyManager
|
||||
{
|
||||
/**
|
||||
* Generate queries that create row-level security policies for tables.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function generateQueries(): array;
|
||||
}
|
||||
260
src/RLS/PolicyManagers/TableRLSManager.php
Normal file
260
src/RLS/PolicyManagers/TableRLSManager.php
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Stancl\Tenancy\RLS\PolicyManagers;
|
||||
|
||||
use Illuminate\Database\DatabaseManager;
|
||||
use Stancl\Tenancy\Database\Exceptions\RecursiveRelationshipException;
|
||||
|
||||
// todo@samuel logical + structural refactor. the tree generation could use some dynamic programming optimizations
|
||||
class TableRLSManager implements RLSPolicyManager
|
||||
{
|
||||
public static bool $scopeByDefault = true;
|
||||
|
||||
public function __construct(
|
||||
protected DatabaseManager $database
|
||||
) {}
|
||||
|
||||
public function generateQueries(array $trees = []): array
|
||||
{
|
||||
$queries = [];
|
||||
|
||||
foreach ($trees ?: $this->shortestPaths() as $table => $path) {
|
||||
$queries[$table] = $this->generateQuery($table, $path);
|
||||
}
|
||||
|
||||
return $queries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce trees to shortest paths (structured like ['table_foo' => $shortestPathForFoo, 'table_bar' => $shortestPathForBar]).
|
||||
*
|
||||
* For example:
|
||||
*
|
||||
* 'posts' => [
|
||||
* [
|
||||
* 'foreignKey' => 'tenant_id',
|
||||
* 'foreignTable' => 'tenants',
|
||||
* 'foreignId' => 'id'
|
||||
* ],
|
||||
* ],
|
||||
* 'comments' => [
|
||||
* [
|
||||
* 'foreignKey' => 'post_id',
|
||||
* 'foreignTable' => 'posts',
|
||||
* 'foreignId' => 'id'
|
||||
* ],
|
||||
* [
|
||||
* 'foreignKey' => 'tenant_id',
|
||||
* 'foreignTable' => 'tenants',
|
||||
* 'foreignId' => 'id'
|
||||
* ],
|
||||
* ],
|
||||
*/
|
||||
public function shortestPaths(array $trees = []): array
|
||||
{
|
||||
$reducedTrees = [];
|
||||
|
||||
foreach ($trees ?: $this->generateTrees() as $table => $tree) {
|
||||
$reducedTrees[$table] = $this->findShortestPath($this->filterNonNullablePaths($tree) ?: $tree);
|
||||
}
|
||||
|
||||
return $reducedTrees;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate trees of paths that lead to the tenants table
|
||||
* for the foreign keys of all tables – only the paths that lead to the tenants table are included.
|
||||
*
|
||||
* Also unset the 'comment' key from the retrieved path steps.
|
||||
*/
|
||||
public function generateTrees(): array
|
||||
{
|
||||
$trees = [];
|
||||
$builder = $this->database->getSchemaBuilder();
|
||||
|
||||
// We loop through each table in the database
|
||||
foreach ($builder->getTableListing() as $table) {
|
||||
// For each table, we get a list of all foreign key columns
|
||||
$foreignKeys = collect($builder->getForeignKeys($table))->map(function ($foreign) use ($table) {
|
||||
return $this->formatForeignKey($foreign, $table);
|
||||
});
|
||||
|
||||
// We loop through each foreign key column and find
|
||||
// all possible paths that lead to the tenants table
|
||||
foreach ($foreignKeys as $foreign) {
|
||||
$paths = [];
|
||||
|
||||
$this->generatePaths($table, $foreign, $paths);
|
||||
|
||||
foreach ($paths as &$path) {
|
||||
foreach ($path as &$step) {
|
||||
unset($step['comment']);
|
||||
}
|
||||
}
|
||||
|
||||
if (count($paths)) {
|
||||
$trees[$table][$foreign['foreignKey']] = $paths;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $trees;
|
||||
}
|
||||
|
||||
protected function generatePaths(string $table, array $foreign, array &$paths, array $currentPath = []): void
|
||||
{
|
||||
if (in_array($foreign['foreignTable'], array_column($currentPath, 'foreignTable'))) {
|
||||
throw new RecursiveRelationshipException;
|
||||
}
|
||||
|
||||
$currentPath[] = $foreign;
|
||||
|
||||
if ($foreign['foreignTable'] === tenancy()->model()->getTable()) {
|
||||
$comments = array_column($currentPath, 'comment');
|
||||
$pathCanUseRls = static::$scopeByDefault ?
|
||||
! in_array('no-rls', $comments) :
|
||||
! in_array('no-rls', $comments) && ! in_array(null, $comments);
|
||||
|
||||
if ($pathCanUseRls) {
|
||||
// If the foreign table is the tenants table, add the current path to $paths
|
||||
$paths[] = $currentPath;
|
||||
}
|
||||
} else {
|
||||
// If not, recursively generate paths for the foreign table
|
||||
foreach ($this->database->getSchemaBuilder()->getForeignKeys($foreign['foreignTable']) as $nextConstraint) {
|
||||
$this->generatePaths($table, $this->formatForeignKey($nextConstraint, $foreign['foreignTable']), $paths, $currentPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Get tree's non-nullable paths. */
|
||||
protected function filterNonNullablePaths(array $tree): array
|
||||
{
|
||||
$nonNullablePaths = [];
|
||||
|
||||
foreach ($tree as $foreignKey => $paths) {
|
||||
foreach ($paths as $path) {
|
||||
$pathIsNullable = false;
|
||||
|
||||
foreach ($path as $step) {
|
||||
if ($step['nullable']) {
|
||||
$pathIsNullable = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $pathIsNullable) {
|
||||
$nonNullablePaths[$foreignKey][] = $path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $nonNullablePaths;
|
||||
}
|
||||
|
||||
/** Find the shortest path in a tree and unset the 'nullable' key from the path steps. */
|
||||
protected function findShortestPath(array $tree): array
|
||||
{
|
||||
$shortestPath = [];
|
||||
|
||||
foreach ($tree as $pathsForForeignKey) {
|
||||
foreach ($pathsForForeignKey as $path) {
|
||||
if (empty($shortestPath) || count($shortestPath) > count($path)) {
|
||||
$shortestPath = $path;
|
||||
|
||||
foreach ($shortestPath as &$step) {
|
||||
unset($step['nullable']);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $shortestPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the foreign key array retrieved by Postgres to a more readable format.
|
||||
*
|
||||
* Also provides information about whether the foreign key is nullable,
|
||||
* and the foreign key column comment. These additional details are removed
|
||||
* from the foreign keys/path steps before returning the final shortest paths.
|
||||
*
|
||||
* The 'comment' key gets deleted while generating the full trees (in generateTrees()),
|
||||
* and the 'nullable' key gets deleted while generating the shortest paths (in findShortestPath()).
|
||||
*
|
||||
* [
|
||||
* 'foreignKey' => 'tenant_id',
|
||||
* 'foreignTable' => 'tenants',
|
||||
* 'foreignId' => 'id',
|
||||
* 'comment' => 'no-rls', // Foreign key comment – used to explicitly enable/disable RLS
|
||||
* 'nullable' => false, // Whether the foreign key is nullable
|
||||
* ].
|
||||
*/
|
||||
protected function formatForeignKey(array $foreignKey, string $table): array
|
||||
{
|
||||
// $foreignKey is one of the foreign keys retrieved by $this->database->getSchemaBuilder()->getForeignKeys($table)
|
||||
return [
|
||||
'foreignKey' => $foreignKeyName = $foreignKey['columns'][0],
|
||||
'foreignTable' => $foreignKey['foreign_table'],
|
||||
'foreignId' => $foreignKey['foreign_columns'][0],
|
||||
// Deleted in generateTrees()
|
||||
'comment' => $this->getComment($table, $foreignKeyName),
|
||||
// Deleted in shortestPaths()
|
||||
'nullable' => $this->database->selectOne("SELECT is_nullable FROM information_schema.columns WHERE table_name = '{$table}' AND column_name = '{$foreignKeyName}'")->is_nullable === 'YES',
|
||||
];
|
||||
}
|
||||
|
||||
/** Generates a query that creates a row-level security policy for the passed table. */
|
||||
protected function generateQuery(string $table, array $path): string
|
||||
{
|
||||
// Generate the SQL conditions recursively
|
||||
$query = "CREATE POLICY {$table}_rls_policy ON {$table} USING (\n";
|
||||
$sessionTenantKey = config('tenancy.rls.session_variable_name');
|
||||
|
||||
foreach ($path as $index => $relation) {
|
||||
$column = $relation['foreignKey'];
|
||||
$table = $relation['foreignTable'];
|
||||
$foreignKey = $relation['foreignId'];
|
||||
|
||||
$indentation = str_repeat(' ', ($index + 1) * 4);
|
||||
|
||||
$query .= $indentation;
|
||||
|
||||
if ($index !== 0) {
|
||||
// On first loop, we don't use a WHERE
|
||||
$query .= 'WHERE ';
|
||||
}
|
||||
|
||||
if ($table === tenancy()->model()->getTable()) {
|
||||
// Convert tenant key to text to match the session variable type
|
||||
$query .= "{$column}::text = current_setting('{$sessionTenantKey}')\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
$query .= "{$column} IN (\n";
|
||||
$query .= $indentation . " SELECT {$foreignKey}\n";
|
||||
$query .= $indentation . " FROM {$table}\n";
|
||||
}
|
||||
|
||||
// Closing ) for each nested WHERE
|
||||
// -1 because the last item is the tenant table reference which is not a nested where
|
||||
for ($i = count($path) - 1; $i > 0; $i--) {
|
||||
$query .= str_repeat(' ', $i * 4) . ")\n";
|
||||
}
|
||||
|
||||
$query .= ');'; // closing for CREATE POLICY
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
protected function getComment(string $tableName, string $columnName): string|null
|
||||
{
|
||||
$column = collect($this->database->getSchemaBuilder()->getColumns($tableName))
|
||||
->filter(fn ($column) => $column['name'] === $columnName)
|
||||
->first();
|
||||
|
||||
return $column['comment'] ?? null;
|
||||
}
|
||||
}
|
||||
134
src/RLS/PolicyManagers/TraitRLSManager.php
Normal file
134
src/RLS/PolicyManagers/TraitRLSManager.php
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Stancl\Tenancy\RLS\PolicyManagers;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Stancl\Tenancy\Database\Concerns\BelongsToPrimaryModel;
|
||||
use Stancl\Tenancy\Database\Concerns\BelongsToTenant;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
use Symfony\Component\Finder\SplFileInfo;
|
||||
|
||||
class TraitRLSManager implements RLSPolicyManager
|
||||
{
|
||||
/** @var Closure: array<\Illuminate\Database\Eloquent\Model> */
|
||||
public static Closure|null $modelDiscoveryOverride = null;
|
||||
|
||||
/**
|
||||
* Directories in which the manager will discover your models.
|
||||
* Subdirectories of the specified directories are also scanned.
|
||||
*
|
||||
* For example, specifying 'app/Models' will discover all models in the 'app/Models' directory and all of its subdirectories.
|
||||
* Specifying 'app/Models/*' will discover all models in the subdirectories of 'app/Models' (+ their subdirectories),
|
||||
* but not the models present directly in the 'app/Models' directory.
|
||||
*/
|
||||
public static array $modelDirectories = ['app/Models'];
|
||||
|
||||
/**
|
||||
* Scope queries of all tenant models using RLS by default.
|
||||
*
|
||||
* To use RLS scoping only for some models, you can keep this disabled and
|
||||
* make the models of your choice implement the RLSModel interface.
|
||||
*/
|
||||
public static bool $implicitRLS = false;
|
||||
|
||||
/** @var array<class-string<\Illuminate\Database\Eloquent\Model>> */
|
||||
public static array $excludedModels = [];
|
||||
|
||||
public function generateQueries(): array
|
||||
{
|
||||
$queries = [];
|
||||
|
||||
foreach ($this->getModels() as $model) {
|
||||
$table = $model->getTable();
|
||||
|
||||
if ($this->modelBelongsToTenant($model)) {
|
||||
$queries[$table] = $this->generateDirectRLSPolicyQuery($model);
|
||||
}
|
||||
|
||||
if ($this->modelBelongsToTenantIndirectly($model)) {
|
||||
$parentRelationship = $model->{$model->getRelationshipToPrimaryModel()}();
|
||||
|
||||
$queries[$table] = $this->generateIndirectRLSPolicyQuery($model, $parentRelationship);
|
||||
}
|
||||
}
|
||||
|
||||
return $queries;
|
||||
}
|
||||
|
||||
protected function generateDirectRLSPolicyQuery(Model $model): string
|
||||
{
|
||||
$table = $model->getTable();
|
||||
$tenantKeyColumn = tenancy()->tenantKeyColumn();
|
||||
$sessionTenantKey = config('tenancy.rls.session_variable_name');
|
||||
|
||||
return <<<SQL
|
||||
CREATE POLICY {$table}_rls_policy ON {$table} USING (
|
||||
{$tenantKeyColumn}::text = current_setting('{$sessionTenantKey}')
|
||||
);
|
||||
SQL;
|
||||
}
|
||||
|
||||
protected function generateIndirectRLSPolicyQuery(Model $model, BelongsTo $parentRelationship): string
|
||||
{
|
||||
$table = $model->getTable();
|
||||
$parent = $parentRelationship->getModel();
|
||||
$tenantKeyColumn = $parent->tenant()->getForeignKeyName();
|
||||
$sessionTenantKey = config('tenancy.rls.session_variable_name');
|
||||
|
||||
return <<<SQL
|
||||
CREATE POLICY {$table}_rls_policy ON {$table} USING (
|
||||
{$parentRelationship->getForeignKeyName()} IN (
|
||||
SELECT {$parent->getKeyName()}
|
||||
FROM {$parent->getTable()}
|
||||
WHERE {$tenantKeyColumn}::text = current_setting('{$sessionTenantKey}')
|
||||
)
|
||||
);
|
||||
SQL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover and retrieve all models.
|
||||
*
|
||||
* Models are either discovered in the directories specified in static::$modelDirectories (by default),
|
||||
* or by a custom closure specified in static::$modelDiscoveryOverride.
|
||||
*
|
||||
* @return array<\Illuminate\Database\Eloquent\Model>
|
||||
*/
|
||||
public function getModels(): array
|
||||
{
|
||||
if (static::$modelDiscoveryOverride) {
|
||||
return (static::$modelDiscoveryOverride)();
|
||||
}
|
||||
|
||||
$modelFiles = Finder::create()->files()->name('*.php')->in(static::$modelDirectories);
|
||||
|
||||
return array_filter(array_map(function (SplFileInfo $file) {
|
||||
$fileContents = str($file->getContents());
|
||||
$class = $fileContents->after("\nclass ")->before("\n")->explode(' ')->first();
|
||||
|
||||
if ($fileContents->contains('namespace ')) {
|
||||
try {
|
||||
return ($fileContents->after('namespace ')->before(';')->toString() . '\\' . $class)::make();
|
||||
} catch (\Throwable $th) {
|
||||
// Skip non-instantiable classes – we only care about models, and those are instantiable
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}, iterator_to_array($modelFiles)), fn (object|null $object) => $object instanceof Model && ! in_array($object::class, static::$excludedModels));
|
||||
}
|
||||
|
||||
public function modelBelongsToTenant(Model $model): bool
|
||||
{
|
||||
return in_array(BelongsToTenant::class, class_uses_recursive($model::class));
|
||||
}
|
||||
|
||||
public function modelBelongsToTenantIndirectly(Model $model): bool
|
||||
{
|
||||
return in_array(BelongsToPrimaryModel::class, class_uses_recursive($model::class));
|
||||
}
|
||||
}
|
||||
|
|
@ -9,13 +9,14 @@ use Illuminate\Database\Eloquent\Builder;
|
|||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Traits\Macroable;
|
||||
use Stancl\Tenancy\Concerns\DealsWithRouteContexts;
|
||||
use Stancl\Tenancy\Concerns\ManagesRLSPolicies;
|
||||
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
|
||||
use Stancl\Tenancy\Contracts\Tenant;
|
||||
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByIdException;
|
||||
|
||||
class Tenancy
|
||||
{
|
||||
use Macroable, DealsWithRouteContexts;
|
||||
use Macroable, DealsWithRouteContexts, ManagesRLSPolicies;
|
||||
|
||||
/**
|
||||
* The current tenant.
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ class TenancyServiceProvider extends ServiceProvider
|
|||
Commands\MigrateFresh::class,
|
||||
Commands\ClearPendingTenants::class,
|
||||
Commands\CreatePendingTenants::class,
|
||||
Commands\CreateUserWithRLSPolicies::class,
|
||||
]);
|
||||
|
||||
$this->app->extend(FreshCommand::class, function ($_, $app) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue