mirror of
https://github.com/archtechx/tenancy.git
synced 2025-12-12 15:34: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
27
.github/workflows/ci.yml
vendored
27
.github/workflows/ci.yml
vendored
|
|
@ -22,13 +22,35 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
- name: Install Composer dependencies
|
- name: Install Composer dependencies
|
||||||
run: |
|
run: |
|
||||||
composer require "laravel/framework:${{ matrix.laravel }}" --no-interaction --no-update
|
composer require "laravel/framework:${{ matrix.laravel }}" --no-interaction --no-update
|
||||||
composer update --prefer-dist --no-interaction
|
composer update --prefer-dist --no-interaction
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: COLUMNS=200 ./vendor/bin/pest --compact --colors=always
|
if: ${{ ! env.ACT }}
|
||||||
|
run: COLUMNS=200 ./vendor/bin/pest -v --compact --colors=always
|
||||||
|
env:
|
||||||
|
DB_PASSWORD: password
|
||||||
|
DB_USERNAME: root
|
||||||
|
DB_DATABASE: main
|
||||||
|
TENANCY_TEST_MYSQL_HOST: mysql
|
||||||
|
TENANCY_TEST_PGSQL_HOST: postgres
|
||||||
|
TENANCY_TEST_REDIS_HOST: redis
|
||||||
|
TENANCY_TEST_SQLSRV_HOST: mssql
|
||||||
|
- name: Run tests (via act, no filter)
|
||||||
|
if: ${{ env.ACT && ! github.event.inputs.FILTER }}
|
||||||
|
run: COLUMNS=200 ./vendor/bin/pest -v --compact --colors=always
|
||||||
|
env:
|
||||||
|
DB_PASSWORD: password
|
||||||
|
DB_USERNAME: root
|
||||||
|
DB_DATABASE: main
|
||||||
|
TENANCY_TEST_MYSQL_HOST: mysql
|
||||||
|
TENANCY_TEST_PGSQL_HOST: postgres
|
||||||
|
TENANCY_TEST_REDIS_HOST: redis
|
||||||
|
TENANCY_TEST_SQLSRV_HOST: mssql
|
||||||
|
- name: Run tests (via act, FILTERED)
|
||||||
|
if: ${{ env.ACT && github.event.inputs.FILTER }}
|
||||||
|
run: COLUMNS=200 ./vendor/bin/pest -v --filter ${{ github.event.inputs.FILTER }} --compact --colors=always
|
||||||
env:
|
env:
|
||||||
DB_PASSWORD: password
|
DB_PASSWORD: password
|
||||||
DB_USERNAME: root
|
DB_USERNAME: root
|
||||||
|
|
@ -39,6 +61,7 @@ jobs:
|
||||||
TENANCY_TEST_SQLSRV_HOST: mssql
|
TENANCY_TEST_SQLSRV_HOST: mssql
|
||||||
|
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
|
if: ${{ !env.ACT }}
|
||||||
uses: codecov/codecov-action@v2
|
uses: codecov/codecov-action@v2
|
||||||
with:
|
with:
|
||||||
token: 24382d15-84e7-4a55-bea4-c4df96a24a9b # todo it's fine if this is here in plaintext, but move this to GH secrets eventually
|
token: 24382d15-84e7-4a55-bea4-c4df96a24a9b # todo it's fine if this is here in plaintext, but move this to GH secrets eventually
|
||||||
|
|
|
||||||
|
|
@ -10,3 +10,9 @@
|
||||||
1. Tag a new image: `docker tag tenancy-test archtechx/tenancy:latest`
|
1. Tag a new image: `docker tag tenancy-test archtechx/tenancy:latest`
|
||||||
1. Push the image: `docker push archtechx/tenancy:latest`
|
1. Push the image: `docker push archtechx/tenancy:latest`
|
||||||
1. Optional: Rebuild the image again locally for arm64: `composer docker-rebuild`
|
1. Optional: Rebuild the image again locally for arm64: `composer docker-rebuild`
|
||||||
|
|
||||||
|
## Debugging GitHub Actions
|
||||||
|
|
||||||
|
The `ci.yml` workflow includes support for [act](https://github.com/nektos/act).
|
||||||
|
|
||||||
|
To run all tests using act, run `composer act`. To run only certain tests using act, use `composer act-input "FILTER='some test name'"` or `composer act -- --input "FILTER='some test name'"`.
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,8 @@ return [
|
||||||
// Integration bootstrappers
|
// Integration bootstrappers
|
||||||
// Bootstrappers\Integrations\FortifyRouteBootstrapper::class,
|
// Bootstrappers\Integrations\FortifyRouteBootstrapper::class,
|
||||||
// Bootstrappers\Integrations\ScoutPrefixBootstrapper::class,
|
// Bootstrappers\Integrations\ScoutPrefixBootstrapper::class,
|
||||||
|
|
||||||
|
// Bootstrappers\PostgresRLSBootstrapper::class,
|
||||||
],
|
],
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -215,6 +217,35 @@ return [
|
||||||
'drop_tenant_databases_on_migrate_fresh' => false,
|
'drop_tenant_databases_on_migrate_fresh' => false,
|
||||||
],
|
],
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requires PostgreSQL with single-database tenancy.
|
||||||
|
*/
|
||||||
|
'rls' => [
|
||||||
|
/**
|
||||||
|
* The RLS manager responsible for generating queries for creating policies.
|
||||||
|
*
|
||||||
|
* @see Stancl\Tenancy\RLS\PolicyManagers\TableRLSManager
|
||||||
|
* @see Stancl\Tenancy\RLS\PolicyManagers\TraitRLSManager
|
||||||
|
*/
|
||||||
|
'manager' => Stancl\Tenancy\RLS\PolicyManagers\TableRLSManager::class,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Credentials for the tenant database user (one user for *all* tenants, not for each tenant).
|
||||||
|
*/
|
||||||
|
'user' => [
|
||||||
|
'username' => env('TENANCY_RLS_USERNAME'),
|
||||||
|
'password' => env('TENANCY_RLS_PASSWORD'),
|
||||||
|
],
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Postgres session variable used to store the current tenant key.
|
||||||
|
*
|
||||||
|
* The variable name has to include a namespace – for example, 'my.'.
|
||||||
|
* The namespace is required because the global one is reserved for the server configuration
|
||||||
|
*/
|
||||||
|
'session_variable_name' => 'my.current_tenant',
|
||||||
|
],
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache tenancy config. Used by the CacheTenancyBootstrapper, the CacheTagsBootstrapper, and the custom CacheManager.
|
* Cache tenancy config. Used by the CacheTenancyBootstrapper, the CacheTagsBootstrapper, and the custom CacheManager.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,10 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
use Stancl\Tenancy\Tenancy;
|
use Stancl\Tenancy\Tenancy;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
|
||||||
return new class extends Migration
|
return new class extends Migration
|
||||||
{
|
{
|
||||||
|
|
@ -19,7 +19,7 @@ return new class extends Migration
|
||||||
Schema::create('domains', function (Blueprint $table) {
|
Schema::create('domains', function (Blueprint $table) {
|
||||||
$table->increments('id');
|
$table->increments('id');
|
||||||
$table->string('domain', 255)->unique();
|
$table->string('domain', 255)->unique();
|
||||||
$table->string(Tenancy::tenantKeyColumn());
|
$table->string(Tenancy::tenantKeyColumn())->comment('no-rls');
|
||||||
|
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
$table->foreign(Tenancy::tenantKeyColumn())->references('id')->on('tenants')->onUpdate('cascade');
|
$table->foreign(Tenancy::tenantKeyColumn())->references('id')->on('tenants')->onUpdate('cascade');
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,9 @@
|
||||||
"phpstan-pro": "vendor/bin/phpstan --pro",
|
"phpstan-pro": "vendor/bin/phpstan --pro",
|
||||||
"cs": "php-cs-fixer fix --config=.php-cs-fixer.php",
|
"cs": "php-cs-fixer fix --config=.php-cs-fixer.php",
|
||||||
"test": "./test --no-coverage",
|
"test": "./test --no-coverage",
|
||||||
"test-full": "./test"
|
"test-full": "./test",
|
||||||
|
"act": "act -j tests --matrix 'laravel:^11.0'",
|
||||||
|
"act-input": "act -j tests --matrix 'laravel:^11.0' --input"
|
||||||
},
|
},
|
||||||
"minimum-stability": "dev",
|
"minimum-stability": "dev",
|
||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ services:
|
||||||
tmpfs:
|
tmpfs:
|
||||||
- /var/lib/mysql
|
- /var/lib/mysql
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:11
|
image: postgres:16
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_PASSWORD: password
|
POSTGRES_PASSWORD: password
|
||||||
POSTGRES_USER: root # superuser name
|
POSTGRES_USER: root # superuser name
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,14 @@ parameters:
|
||||||
paths:
|
paths:
|
||||||
- src/Controllers/TenantAssetController.php
|
- src/Controllers/TenantAssetController.php
|
||||||
- '#expects int<1, max>, int given#'
|
- '#expects int<1, max>, int given#'
|
||||||
|
-
|
||||||
|
message: '#Call to an undefined method Illuminate\\Database\\Eloquent\\Model\:\:tenant\(\)#'
|
||||||
|
paths:
|
||||||
|
- src/RLS/PolicyManagers/TraitRLSManager.php
|
||||||
|
-
|
||||||
|
message: '#Call to an undefined method Illuminate\\Database\\Eloquent\\Model\:\:getRelationshipToPrimaryModel\(\)#'
|
||||||
|
paths:
|
||||||
|
- src/RLS/PolicyManagers/TraitRLSManager.php
|
||||||
|
|
||||||
checkMissingIterableValueType: false
|
checkMissingIterableValueType: false
|
||||||
checkGenericClassInNonGenericObjectType: false # later we may want to enable this
|
checkGenericClassInNonGenericObjectType: false # later we may want to enable this
|
||||||
|
|
|
||||||
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) {
|
if (tenancy()->initialized) {
|
||||||
// Tenancy is already initialized
|
// Tenancy is already initialized
|
||||||
if (tenant()->getTenantKey() === $tenantId) {
|
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;
|
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;
|
namespace Stancl\Tenancy\Database\Concerns;
|
||||||
|
|
||||||
use Stancl\Tenancy\Database\ParentModelScope;
|
use Stancl\Tenancy\Database\ParentModelScope;
|
||||||
|
use Stancl\Tenancy\RLS\PolicyManagers\TraitRLSManager;
|
||||||
|
|
||||||
trait BelongsToPrimaryModel
|
trait BelongsToPrimaryModel
|
||||||
{
|
{
|
||||||
|
|
@ -12,6 +13,10 @@ trait BelongsToPrimaryModel
|
||||||
|
|
||||||
public static function bootBelongsToPrimaryModel(): void
|
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 Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Stancl\Tenancy\Contracts\Tenant;
|
use Stancl\Tenancy\Contracts\Tenant;
|
||||||
use Stancl\Tenancy\Database\TenantScope;
|
use Stancl\Tenancy\Database\TenantScope;
|
||||||
|
use Stancl\Tenancy\RLS\PolicyManagers\TraitRLSManager;
|
||||||
use Stancl\Tenancy\Tenancy;
|
use Stancl\Tenancy\Tenancy;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -14,6 +15,8 @@ use Stancl\Tenancy\Tenancy;
|
||||||
*/
|
*/
|
||||||
trait BelongsToTenant
|
trait BelongsToTenant
|
||||||
{
|
{
|
||||||
|
use FillsCurrentTenant;
|
||||||
|
|
||||||
public function tenant(): BelongsTo
|
public function tenant(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(config('tenancy.models.tenant'), Tenancy::tenantKeyColumn());
|
return $this->belongsTo(config('tenancy.models.tenant'), Tenancy::tenantKeyColumn());
|
||||||
|
|
@ -21,15 +24,12 @@ trait BelongsToTenant
|
||||||
|
|
||||||
public static function bootBelongsToTenant(): void
|
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 (! $implicitRLS && ! (new static) instanceof RLSModel) {
|
||||||
if (! $model->getAttribute(Tenancy::tenantKeyColumn()) && ! $model->relationLoaded('tenant')) {
|
static::addGlobalScope(new TenantScope);
|
||||||
if (tenancy()->initialized) {
|
}
|
||||||
$model->setAttribute(Tenancy::tenantKeyColumn(), tenant()->getTenantKey());
|
|
||||||
$model->setRelation('tenant', tenant());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,9 @@ trait CreatesDatabaseUsers
|
||||||
|
|
||||||
public function deleteDatabase(TenantWithDatabase $tenant): bool
|
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
|
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\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Traits\Macroable;
|
use Illuminate\Support\Traits\Macroable;
|
||||||
use Stancl\Tenancy\Concerns\DealsWithRouteContexts;
|
use Stancl\Tenancy\Concerns\DealsWithRouteContexts;
|
||||||
|
use Stancl\Tenancy\Concerns\ManagesRLSPolicies;
|
||||||
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
|
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
|
||||||
use Stancl\Tenancy\Contracts\Tenant;
|
use Stancl\Tenancy\Contracts\Tenant;
|
||||||
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByIdException;
|
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByIdException;
|
||||||
|
|
||||||
class Tenancy
|
class Tenancy
|
||||||
{
|
{
|
||||||
use Macroable, DealsWithRouteContexts;
|
use Macroable, DealsWithRouteContexts, ManagesRLSPolicies;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The current tenant.
|
* The current tenant.
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@ class TenancyServiceProvider extends ServiceProvider
|
||||||
Commands\MigrateFresh::class,
|
Commands\MigrateFresh::class,
|
||||||
Commands\ClearPendingTenants::class,
|
Commands\ClearPendingTenants::class,
|
||||||
Commands\CreatePendingTenants::class,
|
Commands\CreatePendingTenants::class,
|
||||||
|
Commands\CreateUserWithRLSPolicies::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->app->extend(FreshCommand::class, function ($_, $app) {
|
$this->app->extend(FreshCommand::class, function ($_, $app) {
|
||||||
|
|
|
||||||
|
|
@ -13,18 +13,23 @@ use Stancl\Tenancy\Events\DatabaseCreated;
|
||||||
use Stancl\Tenancy\Database\DatabaseManager;
|
use Stancl\Tenancy\Database\DatabaseManager;
|
||||||
use Stancl\Tenancy\Events\TenancyInitialized;
|
use Stancl\Tenancy\Events\TenancyInitialized;
|
||||||
use Stancl\Tenancy\Listeners\BootstrapTenancy;
|
use Stancl\Tenancy\Listeners\BootstrapTenancy;
|
||||||
use Stancl\Tenancy\Contracts\ManagesDatabaseUsers;
|
use Stancl\Tenancy\Database\Contracts\ManagesDatabaseUsers;
|
||||||
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
|
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
|
||||||
use Stancl\Tenancy\Database\TenantDatabaseManagers\MySQLDatabaseManager;
|
use Stancl\Tenancy\Database\TenantDatabaseManagers\MySQLDatabaseManager;
|
||||||
|
use Stancl\Tenancy\Database\TenantDatabaseManagers\PostgreSQLSchemaManager;
|
||||||
|
use Stancl\Tenancy\Database\TenantDatabaseManagers\PostgreSQLDatabaseManager;
|
||||||
use Stancl\Tenancy\Database\TenantDatabaseManagers\MicrosoftSQLDatabaseManager;
|
use Stancl\Tenancy\Database\TenantDatabaseManagers\MicrosoftSQLDatabaseManager;
|
||||||
use Stancl\Tenancy\Database\Exceptions\TenantDatabaseUserAlreadyExistsException;
|
use Stancl\Tenancy\Database\Exceptions\TenantDatabaseUserAlreadyExistsException;
|
||||||
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledMySQLDatabaseManager;
|
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledMySQLDatabaseManager;
|
||||||
|
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQLSchemaManager;
|
||||||
|
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQLDatabaseManager;
|
||||||
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledMicrosoftSQLServerDatabaseManager;
|
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledMicrosoftSQLServerDatabaseManager;
|
||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
config([
|
config([
|
||||||
'tenancy.database.managers.mysql' => PermissionControlledMySQLDatabaseManager::class,
|
'tenancy.database.managers.mysql' => PermissionControlledMySQLDatabaseManager::class,
|
||||||
'tenancy.database.managers.sqlsrv' => PermissionControlledMicrosoftSQLServerDatabaseManager::class,
|
'tenancy.database.managers.sqlsrv' => PermissionControlledMicrosoftSQLServerDatabaseManager::class,
|
||||||
|
'tenancy.database.managers.pgsql' => PermissionControlledPostgreSQLDatabaseManager::class,
|
||||||
'tenancy.database.suffix' => '',
|
'tenancy.database.suffix' => '',
|
||||||
'tenancy.database.template_tenant_connection' => 'mysql',
|
'tenancy.database.template_tenant_connection' => 'mysql',
|
||||||
]);
|
]);
|
||||||
|
|
@ -36,12 +41,20 @@ beforeEach(function () {
|
||||||
'SHOW VIEW', 'TRIGGER', 'UPDATE',
|
'SHOW VIEW', 'TRIGGER', 'UPDATE',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
PermissionControlledMicrosoftSQLServerDatabaseManager::$grants = [
|
||||||
|
'SELECT', 'INSERT', 'UPDATE', 'DELETE', 'EXECUTE',
|
||||||
|
];
|
||||||
|
|
||||||
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
|
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
|
||||||
return $event->tenant;
|
return $event->tenant;
|
||||||
})->toListener());
|
})->toListener());
|
||||||
});
|
});
|
||||||
|
|
||||||
test('users are created when permission controlled manager is used', function (string $connection) {
|
test('users are created when permission controlled manager is used', function (string $connection, string|null $manager = null) {
|
||||||
|
if ($manager) {
|
||||||
|
config(["tenancy.database.managers.{$connection}" => $manager]);
|
||||||
|
}
|
||||||
|
|
||||||
config([
|
config([
|
||||||
'database.default' => $connection,
|
'database.default' => $connection,
|
||||||
'tenancy.database.template_tenant_connection' => $connection,
|
'tenancy.database.template_tenant_connection' => $connection,
|
||||||
|
|
@ -69,11 +82,17 @@ test('users are created when permission controlled manager is used', function (s
|
||||||
expect((bool) DB::select("SELECT dp.name as username FROM sys.database_principals dp WHERE dp.name = '{$username}'"))->toBeTrue();
|
expect((bool) DB::select("SELECT dp.name as username FROM sys.database_principals dp WHERE dp.name = '{$username}'"))->toBeTrue();
|
||||||
}
|
}
|
||||||
})->with([
|
})->with([
|
||||||
'mysql',
|
['mysql'],
|
||||||
'sqlsrv',
|
['sqlsrv'],
|
||||||
|
['pgsql', PermissionControlledPostgreSQLDatabaseManager::class],
|
||||||
|
['pgsql', PermissionControlledPostgreSQLSchemaManager::class],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
test('a tenants database cannot be created when the user already exists', function (string $connection) {
|
test('a tenants database cannot be created when the user already exists', function (string $connection, string|null $manager = null) {
|
||||||
|
if ($manager) {
|
||||||
|
config(["tenancy.database.managers.{$connection}" => $manager]);
|
||||||
|
}
|
||||||
|
|
||||||
config([
|
config([
|
||||||
'database.default' => $connection,
|
'database.default' => $connection,
|
||||||
'tenancy.database.template_tenant_connection' => $connection,
|
'tenancy.database.template_tenant_connection' => $connection,
|
||||||
|
|
@ -103,8 +122,10 @@ test('a tenants database cannot be created when the user already exists', functi
|
||||||
expect($manager2->databaseExists($tenant2->database()->getName()))->toBeFalse();
|
expect($manager2->databaseExists($tenant2->database()->getName()))->toBeFalse();
|
||||||
Event::assertNotDispatched(DatabaseCreated::class);
|
Event::assertNotDispatched(DatabaseCreated::class);
|
||||||
})->with([
|
})->with([
|
||||||
'mysql',
|
['mysql'],
|
||||||
'sqlsrv',
|
['sqlsrv'],
|
||||||
|
['pgsql', PermissionControlledPostgreSQLDatabaseManager::class],
|
||||||
|
['pgsql', PermissionControlledPostgreSQLSchemaManager::class],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
test('correct grants are given to users using mysql', function () {
|
test('correct grants are given to users using mysql', function () {
|
||||||
|
|
@ -120,6 +141,33 @@ test('correct grants are given to users using mysql', function () {
|
||||||
expect($query->{"Grants for {$user}@%"})->toStartWith('GRANT CREATE, ALTER, ALTER ROUTINE ON'); // @mysql because that's the hostname within the docker network
|
expect($query->{"Grants for {$user}@%"})->toStartWith('GRANT CREATE, ALTER, ALTER ROUTINE ON'); // @mysql because that's the hostname within the docker network
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('permissions for new tables are granted to users using pgsql', function (string $manager) {
|
||||||
|
config([
|
||||||
|
'database.default' => 'pgsql',
|
||||||
|
'tenancy.database.template_tenant_connection' => 'pgsql',
|
||||||
|
'tenancy.database.managers.pgsql' => $manager,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Tenant::create(['tenancy_db_username' => $username = 'user' . Str::random(8)]);
|
||||||
|
|
||||||
|
$grantCount = fn () => count(DB::select("SELECT * FROM information_schema.table_privileges WHERE grantee = '{$username}'"));
|
||||||
|
|
||||||
|
expect($grantCount())->toBe(0);
|
||||||
|
|
||||||
|
Event::listen(TenancyInitialized::class, function (TenancyInitialized $event) {
|
||||||
|
app(DatabaseManager::class)->connectToTenant($event->tenancy->tenant);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run tenants:migrate to create tables to confirm
|
||||||
|
// that the user will be granted privileges for newly created tables
|
||||||
|
pest()->artisan('tenants:migrate');
|
||||||
|
|
||||||
|
expect($grantCount())->not()->toBe(0);
|
||||||
|
})->with([
|
||||||
|
PermissionControlledPostgreSQLDatabaseManager::class,
|
||||||
|
PermissionControlledPostgreSQLSchemaManager::class
|
||||||
|
]);
|
||||||
|
|
||||||
test('correct grants are given to users using sqlsrv', function () {
|
test('correct grants are given to users using sqlsrv', function () {
|
||||||
config([
|
config([
|
||||||
'database.default' => 'sqlsrv',
|
'database.default' => 'sqlsrv',
|
||||||
|
|
@ -141,10 +189,11 @@ test('correct grants are given to users using sqlsrv', function () {
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('having existing databases without users and switching to permission controlled mysql manager doesnt break existing dbs', function () {
|
test('having existing databases without users and switching to permission controlled manager doesnt break existing dbs', function (string $driver, string $manager, string $permissionControlledManager, string $defaultUser) {
|
||||||
config([
|
config([
|
||||||
'tenancy.database.managers.mysql' => MySQLDatabaseManager::class,
|
'database.default' => $driver,
|
||||||
'tenancy.database.template_tenant_connection' => 'mysql',
|
'tenancy.database.managers.' . $driver => $manager,
|
||||||
|
'tenancy.database.template_tenant_connection' => $driver,
|
||||||
'tenancy.bootstrappers' => [
|
'tenancy.bootstrappers' => [
|
||||||
DatabaseTenancyBootstrapper::class,
|
DatabaseTenancyBootstrapper::class,
|
||||||
],
|
],
|
||||||
|
|
@ -156,44 +205,20 @@ test('having existing databases without users and switching to permission contro
|
||||||
'id' => 'foo' . Str::random(10),
|
'id' => 'foo' . Str::random(10),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect($tenant->database()->manager() instanceof MySQLDatabaseManager)->toBeTrue();
|
expect($tenant->database()->manager() instanceof $manager)->toBeTrue();
|
||||||
|
|
||||||
tenancy()->initialize($tenant); // check if everything works
|
tenancy()->initialize($tenant); // check if everything works
|
||||||
tenancy()->end();
|
tenancy()->end();
|
||||||
|
|
||||||
config(['tenancy.database.managers.mysql' => PermissionControlledMySQLDatabaseManager::class]);
|
config(['tenancy.database.managers.' . $driver => $permissionControlledManager]);
|
||||||
|
|
||||||
tenancy()->initialize($tenant); // check if everything works
|
tenancy()->initialize($tenant); // check if everything works
|
||||||
|
|
||||||
expect($tenant->database()->manager() instanceof PermissionControlledMySQLDatabaseManager)->toBeTrue();
|
expect($tenant->database()->manager() instanceof $permissionControlledManager)->toBeTrue();
|
||||||
expect(config('database.connections.tenant.username'))->toBe('root');
|
expect(config('database.connections.tenant.username'))->toBe($defaultUser);
|
||||||
});
|
})->with([
|
||||||
|
['mysql', MySQLDatabaseManager::class, PermissionControlledMySQLDatabaseManager::class, 'root'],
|
||||||
test('having existing databases without users and switching to permission controlled sqlsrv manager doesnt break existing dbs', function () {
|
['pgsql', PostgreSQLDatabaseManager::class, PermissionControlledPostgreSQLDatabaseManager::class, 'root'],
|
||||||
config([
|
['pgsql', PostgreSQLSchemaManager::class, PermissionControlledPostgreSQLSchemaManager::class, 'root'],
|
||||||
'database.default' => 'sqlsrv',
|
['sqlsrv', MicrosoftSQLDatabaseManager::class, PermissionControlledMicrosoftSQLServerDatabaseManager::class, 'sa'],
|
||||||
'tenancy.database.managers.sqlsrv' => MicrosoftSQLDatabaseManager::class,
|
]);
|
||||||
'tenancy.database.template_tenant_connection' => 'sqlsrv',
|
|
||||||
'tenancy.bootstrappers' => [
|
|
||||||
DatabaseTenancyBootstrapper::class,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
|
|
||||||
|
|
||||||
$tenant = Tenant::create([
|
|
||||||
'id' => 'foo' . Str::random(10),
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect($tenant->database()->manager() instanceof MicrosoftSQLDatabaseManager)->toBeTrue();
|
|
||||||
|
|
||||||
tenancy()->initialize($tenant); // check if everything works
|
|
||||||
tenancy()->end();
|
|
||||||
|
|
||||||
config(['tenancy.database.managers.sqlsrv' => PermissionControlledMicrosoftSQLServerDatabaseManager::class]);
|
|
||||||
|
|
||||||
tenancy()->initialize($tenant); // check if everything works
|
|
||||||
|
|
||||||
expect($tenant->database()->manager() instanceof PermissionControlledMicrosoftSQLServerDatabaseManager)->toBeTrue();
|
|
||||||
expect(config('database.connections.tenant.username'))->toBe('sa'); // default user for the sqlsrv connection
|
|
||||||
});
|
|
||||||
|
|
|
||||||
15
tests/RLS/Etc/Article.php
Normal file
15
tests/RLS/Etc/Article.php
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Stancl\Tenancy\Tests\RLS\Etc;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Stancl\Tenancy\Database\Concerns\BelongsToTenant;
|
||||||
|
use Stancl\Tenancy\Database\Concerns\RLSModel;
|
||||||
|
|
||||||
|
/** Used for testing TraitRLSManager */
|
||||||
|
class Article extends Model implements RLSModel
|
||||||
|
{
|
||||||
|
use BelongsToTenant;
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
}
|
||||||
27
tests/RLS/Etc/Comment.php
Normal file
27
tests/RLS/Etc/Comment.php
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Stancl\Tenancy\Tests\RLS\Etc;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Stancl\Tenancy\Database\Concerns\BelongsToPrimaryModel;
|
||||||
|
use Stancl\Tenancy\Database\Concerns\RLSModel;
|
||||||
|
|
||||||
|
class Comment extends Model implements RLSModel
|
||||||
|
{
|
||||||
|
use BelongsToPrimaryModel;
|
||||||
|
|
||||||
|
public $guarded = [];
|
||||||
|
|
||||||
|
public $table = 'comments';
|
||||||
|
|
||||||
|
public function getRelationshipToPrimaryModel(): string
|
||||||
|
{
|
||||||
|
return 'post';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function post(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Post::class, 'post_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
25
tests/RLS/Etc/Post.php
Normal file
25
tests/RLS/Etc/Post.php
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Stancl\Tenancy\Tests\RLS\Etc;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Stancl\Tenancy\Database\Concerns\RLSModel;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Stancl\Tenancy\Database\Concerns\BelongsToTenant;
|
||||||
|
|
||||||
|
/** Used for testing TraitRLSManager */
|
||||||
|
class Post extends Model implements RLSModel
|
||||||
|
{
|
||||||
|
use BelongsToTenant;
|
||||||
|
|
||||||
|
public $table = 'posts';
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
public function comments(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Comment::class, 'post_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
197
tests/RLS/PolicyTest.php
Normal file
197
tests/RLS/PolicyTest.php
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\QueryException;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Stancl\Tenancy\Events\TenancyEnded;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Stancl\Tenancy\Tests\RLS\Etc\Article;
|
||||||
|
use Stancl\Tenancy\Events\TenancyInitialized;
|
||||||
|
use Stancl\Tenancy\Listeners\BootstrapTenancy;
|
||||||
|
use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
||||||
|
use Stancl\Tenancy\Commands\CreateUserWithRLSPolicies;
|
||||||
|
use Stancl\Tenancy\RLS\PolicyManagers\TableRLSManager;
|
||||||
|
use Stancl\Tenancy\RLS\PolicyManagers\TraitRLSManager;
|
||||||
|
use Stancl\Tenancy\Bootstrappers\PostgresRLSBootstrapper;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
TraitRLSManager::$excludedModels = [Article::class];
|
||||||
|
TraitRLSManager::$modelDirectories = [__DIR__ . '/Etc'];
|
||||||
|
|
||||||
|
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
|
||||||
|
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
|
||||||
|
|
||||||
|
DB::purge($centralConnection = config('tenancy.database.central_connection'));
|
||||||
|
config(['database.connections.' . $centralConnection => config('database.connections.pgsql')]);
|
||||||
|
config(['tenancy.models.tenant_key_column' => 'tenant_id']);
|
||||||
|
config(['tenancy.models.tenant' => Tenant::class]);
|
||||||
|
config(['tenancy.bootstrappers' => [PostgresRLSBootstrapper::class]]);
|
||||||
|
config(['tenancy.rls.user' => [
|
||||||
|
'username' => 'username',
|
||||||
|
'password' => 'password',
|
||||||
|
]]);
|
||||||
|
|
||||||
|
// Turn implicit RLS scoping on
|
||||||
|
TraitRLSManager::$implicitRLS = true;
|
||||||
|
|
||||||
|
pest()->artisan('migrate:fresh', [
|
||||||
|
'--force' => true,
|
||||||
|
'--path' => __DIR__ . '/../../assets/migrations',
|
||||||
|
'--realpath' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Schema::create('authors', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('tenant_id');
|
||||||
|
|
||||||
|
$table->foreign('tenant_id')->comment('rls')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('posts', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('text');
|
||||||
|
|
||||||
|
// Multiple foreign keys to test if the table manager generates the paths correctly
|
||||||
|
|
||||||
|
// Leads to the tenants table
|
||||||
|
$table->string('tenant_id')->nullable();
|
||||||
|
$table->foreign('tenant_id')->comment('rls')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade');
|
||||||
|
|
||||||
|
// Leads to the tenants table – shortest path (because the tenant key column is nullable – excluded when choosing the shortest path)
|
||||||
|
$table->foreignId('author_id')->comment('rls')->constrained('authors');
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('comments', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('text');
|
||||||
|
$table->foreignId('post_id')->comment('rls')->constrained('posts')->onUpdate('cascade')->onDelete('cascade');
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('postgres user gets created using the rls command', function(string $manager) {
|
||||||
|
config(['tenancy.rls.manager' => $manager]);
|
||||||
|
|
||||||
|
pest()->artisan('tenants:rls');
|
||||||
|
|
||||||
|
$name = config('tenancy.rls.user.username');
|
||||||
|
|
||||||
|
expect(count(DB::select("SELECT usename FROM pg_user WHERE usename = '$name'")))->toBe(1);
|
||||||
|
})->with([
|
||||||
|
TableRLSManager::class,
|
||||||
|
TraitRLSManager::class,
|
||||||
|
]);
|
||||||
|
|
||||||
|
test('rls command creates rls policies only for tables that do not have them', function (string $manager) {
|
||||||
|
config(['tenancy.rls.manager' => $manager]);
|
||||||
|
|
||||||
|
// Posts + comments (2 tables) with trait manager (authors doesn't have a model with the relevant trait)
|
||||||
|
// Posts + comments + authors (3 tables) with table manager
|
||||||
|
$tableCount = $manager === TraitRLSManager::class ? 2 : 3;
|
||||||
|
|
||||||
|
$policyCount = fn () => count(DB::select('SELECT * FROM pg_policies'));
|
||||||
|
expect($policyCount())->toBe(0);
|
||||||
|
|
||||||
|
pest()->artisan('tenants:rls');
|
||||||
|
expect($policyCount())->toBe($tableCount);
|
||||||
|
|
||||||
|
$policies = DB::select('SELECT * FROM pg_policies');
|
||||||
|
|
||||||
|
DB::statement("DROP POLICY {$policies[0]->policyname} ON {$policies[0]->tablename}");
|
||||||
|
expect($policyCount())->toBe($tableCount - 1); // one deleted
|
||||||
|
|
||||||
|
pest()->artisan('tenants:rls');
|
||||||
|
|
||||||
|
// back to original count
|
||||||
|
expect($policyCount())->toBe($tableCount);
|
||||||
|
expect(DB::select('SELECT * FROM pg_policies'))->toHaveCount(count($policies));
|
||||||
|
})->with([
|
||||||
|
TableRLSManager::class,
|
||||||
|
TraitRLSManager::class,
|
||||||
|
]);
|
||||||
|
|
||||||
|
test('rls command recreates outdated policies', function (string $manager) {
|
||||||
|
config(['tenancy.rls.manager' => $manager]);
|
||||||
|
|
||||||
|
// "test" being an outdated hash
|
||||||
|
DB::statement('CREATE POLICY posts_rls_policy_test ON posts');
|
||||||
|
|
||||||
|
pest()->artisan('tenants:rls');
|
||||||
|
|
||||||
|
expect(DB::selectOne("SELECT policyname FROM pg_policies WHERE tablename = 'posts'")->policyname)
|
||||||
|
->toStartWith('posts_rls_policy_')
|
||||||
|
->not()->toBe('posts_rls_policy_test');
|
||||||
|
})->with([
|
||||||
|
TableRLSManager::class,
|
||||||
|
TraitRLSManager::class,
|
||||||
|
]);
|
||||||
|
|
||||||
|
test('rls command recreates policies if the force option is passed', function (string $manager) {
|
||||||
|
config(['tenancy.rls.manager' => $manager]);
|
||||||
|
|
||||||
|
/** @var CreateUserWithRLSPolicies $policyCreationCommand */
|
||||||
|
$policyCreationCommand = app(CreateUserWithRLSPolicies::class);
|
||||||
|
|
||||||
|
$hash = $policyCreationCommand->hashPolicy(app(config('tenancy.rls.manager'))->generateQueries()['posts'])[0];
|
||||||
|
$policyNameWithHash = "posts_rls_policy_{$hash}";
|
||||||
|
|
||||||
|
DB::enableQueryLog();
|
||||||
|
DB::statement($policyCreationQuery = "CREATE POLICY {$policyNameWithHash} ON posts");
|
||||||
|
|
||||||
|
pest()->artisan('tenants:rls', ['--force' => true]);
|
||||||
|
|
||||||
|
$postsPolicyCreationQueries = collect(DB::getQueryLog())
|
||||||
|
->pluck('query')
|
||||||
|
->filter(fn (string $query) => str($query)->contains($policyCreationQuery));
|
||||||
|
|
||||||
|
expect($postsPolicyCreationQueries)->toHaveCount(2);
|
||||||
|
})->with([
|
||||||
|
TableRLSManager::class,
|
||||||
|
TraitRLSManager::class,
|
||||||
|
]);
|
||||||
|
|
||||||
|
test('queries will stop working when the tenant session variable is not set', function(string $manager) {
|
||||||
|
config(['tenancy.rls.manager' => $manager]);
|
||||||
|
|
||||||
|
$sessionVariableName = config('tenancy.rls.session_variable_name');
|
||||||
|
|
||||||
|
$tenant = Tenant::create();
|
||||||
|
|
||||||
|
pest()->artisan('tenants:rls');
|
||||||
|
|
||||||
|
tenancy()->initialize($tenant);
|
||||||
|
|
||||||
|
// The session variable is set correctly
|
||||||
|
// Creating a record for the current tenant should work
|
||||||
|
$authorId = DB::selectOne(<<<SQL
|
||||||
|
INSERT INTO authors (name, tenant_id)
|
||||||
|
VALUES ('author1', ?)
|
||||||
|
RETURNING id
|
||||||
|
SQL, [$tenant->id])->id;
|
||||||
|
|
||||||
|
expect(fn () => DB::insert(<<<SQL
|
||||||
|
INSERT INTO posts (text, tenant_id, author_id)
|
||||||
|
VALUES ('post1', ?, ?)
|
||||||
|
SQL, [$tenant->id, $authorId]))->not()->toThrow(Exception::class);
|
||||||
|
|
||||||
|
DB::statement("RESET {$sessionVariableName}");
|
||||||
|
|
||||||
|
// Throws RLS violation exception
|
||||||
|
// The session variable is not set to the current tenant key
|
||||||
|
expect(fn () => DB::insert(<<<SQL
|
||||||
|
INSERT INTO posts (text, tenant_id, author_id)
|
||||||
|
VALUES ('post2', ?, ?)
|
||||||
|
SQL, [$tenant->id, $authorId]))->toThrow(QueryException::class);
|
||||||
|
})->with([
|
||||||
|
TableRLSManager::class,
|
||||||
|
TraitRLSManager::class,
|
||||||
|
]);
|
||||||
705
tests/RLS/TableManagerTest.php
Normal file
705
tests/RLS/TableManagerTest.php
Normal file
|
|
@ -0,0 +1,705 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\QueryException;
|
||||||
|
use Stancl\Tenancy\Events\TenancyEnded;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Stancl\Tenancy\Events\TenancyInitialized;
|
||||||
|
use Stancl\Tenancy\Listeners\BootstrapTenancy;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Stancl\Tenancy\Commands\CreateUserWithRLSPolicies;
|
||||||
|
use Stancl\Tenancy\RLS\PolicyManagers\TableRLSManager;
|
||||||
|
use Stancl\Tenancy\Bootstrappers\PostgresRLSBootstrapper;
|
||||||
|
use Stancl\Tenancy\Database\Exceptions\RecursiveRelationshipException;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
TableRLSManager::$scopeByDefault = true;
|
||||||
|
|
||||||
|
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
|
||||||
|
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
|
||||||
|
|
||||||
|
DB::purge($centralConnection = config('tenancy.database.central_connection'));
|
||||||
|
config(['database.connections.' . $centralConnection => config('database.connections.pgsql')]);
|
||||||
|
config(['tenancy.models.tenant_key_column' => 'tenant_id']);
|
||||||
|
config(['tenancy.models.tenant' => Tenant::class]);
|
||||||
|
config(['tenancy.rls.manager' => TableRLSManager::class]);
|
||||||
|
config(['tenancy.rls.user.username' => 'username']);
|
||||||
|
config(['tenancy.rls.user.password' => 'password']);
|
||||||
|
config(['tenancy.bootstrappers' => [PostgresRLSBootstrapper::class]]);
|
||||||
|
|
||||||
|
pest()->artisan('migrate:fresh', [
|
||||||
|
'--force' => true,
|
||||||
|
'--path' => __DIR__ . '/../../assets/migrations',
|
||||||
|
'--realpath' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Schema::create('authors', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name');
|
||||||
|
|
||||||
|
$table->string('tenant_id')->comment('rls');
|
||||||
|
$table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade');
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('categories', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('tenant_id')->comment('no-rls'); // not scoped
|
||||||
|
$table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade');
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('posts', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('text');
|
||||||
|
|
||||||
|
// Multiple foreign keys to test if the table manager generates the paths correctly
|
||||||
|
|
||||||
|
// Leads to the tenants table, BUT is nullable
|
||||||
|
$table->string('tenant_id')->nullable()->comment('rls');
|
||||||
|
$table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade');
|
||||||
|
|
||||||
|
// Leads to the tenants table – shortest path (because the tenant key column is nullable – excluded when choosing the shortest path)
|
||||||
|
$table->foreignId('author_id')->comment('rls')->constrained('authors');
|
||||||
|
|
||||||
|
// Doesn't lead to the tenants table because of a no-rls comment further down the path – should get excluded from paths entirely
|
||||||
|
$table->foreignId('category_id')->comment('rls')->nullable()->constrained('categories');
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('comments', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('text');
|
||||||
|
$table->foreignId('post_id')->comment('rls')->constrained('posts')->onUpdate('cascade')->onDelete('cascade');
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Related to tenants table only through a no-rls path
|
||||||
|
// Exists to see if the manager correctly excludes it from the paths
|
||||||
|
Schema::create('reactions', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->boolean('like')->default(true);
|
||||||
|
$table->foreignId('comment_id')->comment('no-rls')->constrained('comments')->onUpdate('cascade')->onDelete('cascade');
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Not related to the tenants table in any way
|
||||||
|
// Exists to check that the manager doesn't generate paths for models not related to the tenants table
|
||||||
|
Schema::create('articles', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('text');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('correct rls policies get created with the correct hash using table manager', function() {
|
||||||
|
$manager = app(config('tenancy.rls.manager'));
|
||||||
|
|
||||||
|
$tables = [
|
||||||
|
'authors',
|
||||||
|
'posts',
|
||||||
|
'comments',
|
||||||
|
// The following tables will get completely excluded from policy generation
|
||||||
|
// Because they are only related to the tenants table by paths using the 'no-rls' comment
|
||||||
|
// 'reactions',
|
||||||
|
// 'categories',
|
||||||
|
];
|
||||||
|
|
||||||
|
$getRLSPolicies = fn () => DB::select('SELECT policyname, tablename FROM pg_policies');
|
||||||
|
$getRLSTables = fn () => collect($tables)->map(fn ($table) => DB::select('SELECT relname, relforcerowsecurity FROM pg_class WHERE oid = ?::regclass', [$table]))->collapse();
|
||||||
|
|
||||||
|
// Drop all existing policies to check if the command creates policies for multiple tables
|
||||||
|
foreach ($getRLSPolicies() as $policy) {
|
||||||
|
DB::statement("DROP POLICY IF EXISTS {$policy->policyname} ON {$policy->tablename}");
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($getRLSPolicies())->toHaveCount(0);
|
||||||
|
|
||||||
|
pest()->artisan('tenants:rls');
|
||||||
|
|
||||||
|
// Check if all tables related to the tenant have RLS policies
|
||||||
|
expect($policies = $getRLSPolicies())->toHaveCount(count($tables));
|
||||||
|
expect($rlsTables = $getRLSTables())->toHaveCount(count($tables));
|
||||||
|
|
||||||
|
foreach ($rlsTables as $table) {
|
||||||
|
expect($tables)->toContain($table->relname);
|
||||||
|
expect($table->relforcerowsecurity)->toBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that the policies get suffixed with the correct hash
|
||||||
|
$queries = $manager->generateQueries();
|
||||||
|
expect(array_keys($queries))->toEqualCanonicalizing($tables);
|
||||||
|
expect(array_keys($queries))->not()->toContain('articles');
|
||||||
|
|
||||||
|
/** @var CreateUserWithRLSPolicies $policyCreationCommand */
|
||||||
|
$policyCreationCommand = app(CreateUserWithRLSPolicies::class);
|
||||||
|
|
||||||
|
foreach ($queries as $table => $query) {
|
||||||
|
$policy = collect($policies)->filter(fn (object $policy) => $policy->tablename === $table)->first();
|
||||||
|
$hash = $policyCreationCommand->hashPolicy($query)[0];
|
||||||
|
$policyNameWithHash = "{$table}_rls_policy_{$hash}";
|
||||||
|
|
||||||
|
expect($tables)->toContain($policy->tablename);
|
||||||
|
expect($policy->policyname)->toBe($policyNameWithHash);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('queries are correctly scoped using RLS', function() {
|
||||||
|
// 3-levels deep relationship
|
||||||
|
Schema::create('notes', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('text')->default('foo');
|
||||||
|
// no rls comment needed, $scopeByDefault is set to true
|
||||||
|
$table->foreignId('comment_id')->onUpdate('cascade')->onDelete('cascade')->constrained('comments');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create RLS policies for tables and the tenant user
|
||||||
|
pest()->artisan('tenants:rls');
|
||||||
|
|
||||||
|
// Create two tenants
|
||||||
|
$tenant1 = Tenant::create();
|
||||||
|
$tenant2 = Tenant::create();
|
||||||
|
|
||||||
|
// Create posts and comments for both tenants
|
||||||
|
tenancy()->initialize($tenant1);
|
||||||
|
|
||||||
|
$post1 = Post::create([
|
||||||
|
'text' => 'first post',
|
||||||
|
'tenant_id' => $tenant1->getTenantKey(),
|
||||||
|
'author_id' => Author::create(['name' => 'author1', 'tenant_id' => $tenant1->getTenantKey()])->id,
|
||||||
|
'category_id' => Category::create(['name' => 'category1', 'tenant_id' => $tenant1->getTenantKey()])->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$post1Comment = Comment::create(['text' => 'first comment', 'post_id' => $post1->id]);
|
||||||
|
|
||||||
|
$post1Comment->notes()->create(['text' => 'foo']);
|
||||||
|
|
||||||
|
tenancy()->initialize($tenant2);
|
||||||
|
|
||||||
|
$post2 = Post::create([
|
||||||
|
'text' => 'second post',
|
||||||
|
'tenant_id' => $tenant2->getTenantKey(),
|
||||||
|
'author_id' => Author::create(['name' => 'author2', 'tenant_id' => $tenant2->getTenantKey()])->id,
|
||||||
|
'category_id' => Category::create(['name' => 'category2', 'tenant_id' => $tenant2->getTenantKey()])->id
|
||||||
|
]);
|
||||||
|
|
||||||
|
$post2Comment = Comment::create(['text' => 'second comment', 'post_id' => $post2->id]);
|
||||||
|
|
||||||
|
$post2Comment->notes()->create(['text' => 'bar']);
|
||||||
|
|
||||||
|
tenancy()->initialize($tenant1);
|
||||||
|
|
||||||
|
expect(Post::all()->pluck('text'))
|
||||||
|
->toHaveCount(1)
|
||||||
|
->toContain($post1->text)
|
||||||
|
->not()->toContain($post2->text)
|
||||||
|
->toEqual(Post::withoutGlobalScopes()->get()->pluck('text'));
|
||||||
|
|
||||||
|
expect(Comment::all()->pluck('text'))
|
||||||
|
->toHaveCount(1)
|
||||||
|
->toContain($post1Comment->text)
|
||||||
|
->not()->toContain($post2Comment->text)
|
||||||
|
->toEqual(Comment::withoutGlobalScopes()->get()->pluck('text'));
|
||||||
|
|
||||||
|
expect(Note::all()->pluck('text'))
|
||||||
|
->toHaveCount(1)
|
||||||
|
->toContain('foo') // $note1->text
|
||||||
|
->not()->toContain('bar') // $note2->text
|
||||||
|
->toEqual(Note::withoutGlobalScopes()->get()->pluck('text'));
|
||||||
|
|
||||||
|
tenancy()->end();
|
||||||
|
|
||||||
|
expect(Post::all()->pluck('text'))
|
||||||
|
->toHaveCount(2)
|
||||||
|
->toContain($post1->text)
|
||||||
|
->toContain($post2->text);
|
||||||
|
|
||||||
|
expect(Comment::all()->pluck('text'))
|
||||||
|
->toHaveCount(2)
|
||||||
|
->toContain($post1Comment->text)
|
||||||
|
->toContain($post2Comment->text);
|
||||||
|
|
||||||
|
expect(Note::all()->pluck('text'))
|
||||||
|
->toHaveCount(2)
|
||||||
|
->toContain('foo')
|
||||||
|
->toContain('bar');
|
||||||
|
|
||||||
|
tenancy()->initialize($tenant2);
|
||||||
|
|
||||||
|
expect(Post::all()->pluck('text'))
|
||||||
|
->toHaveCount(1)
|
||||||
|
->toContain($post2->text)
|
||||||
|
->not()->toContain($post1->text)
|
||||||
|
->toEqual(Post::withoutGlobalScopes()->get()->pluck('text'));
|
||||||
|
|
||||||
|
expect(Comment::all()->pluck('text'))
|
||||||
|
->toHaveCount(1)
|
||||||
|
->toContain($post2Comment->text)
|
||||||
|
->not()->toContain($post1Comment->text)
|
||||||
|
->toEqual(Comment::withoutGlobalScopes()->get()->pluck('text'));
|
||||||
|
|
||||||
|
expect(Note::all()->pluck('text'))
|
||||||
|
->toHaveCount(1)
|
||||||
|
->toContain('bar')
|
||||||
|
->not()->toContain('foo')
|
||||||
|
->toEqual(Note::withoutGlobalScopes()->get()->pluck('text'));
|
||||||
|
|
||||||
|
// Test that RLS policies protect tenants from other tenant's direct queries
|
||||||
|
// Try updating records of the other tenant – should have no effect
|
||||||
|
DB::statement("UPDATE posts SET text = 'updated' WHERE id = {$post1->id}");
|
||||||
|
DB::statement("UPDATE comments SET text = 'updated' WHERE id = {$post1Comment->id}");
|
||||||
|
DB::statement("UPDATE notes SET text = 'updated'"); // should only update the current tenant's comments
|
||||||
|
|
||||||
|
// Still in tenant2
|
||||||
|
expect(Note::all()->pluck('text'))
|
||||||
|
->toContain('updated'); // query with no WHERE updated the current tenant's comments
|
||||||
|
expect(Post::all()->pluck('text'))
|
||||||
|
->toContain('second post'); // query with a where targeting another user's post had no effect on the current tenant's posts
|
||||||
|
expect(Comment::all()->pluck('text'))
|
||||||
|
->toContain('second comment'); // query with a where targeting another user's post had no effect on the current tenant's posts
|
||||||
|
|
||||||
|
tenancy()->initialize($tenant1);
|
||||||
|
|
||||||
|
expect(Post::all()->pluck('text'))
|
||||||
|
->toContain($post1->text)
|
||||||
|
->not()->toContain($post2->text)
|
||||||
|
->not()->toContain('updated') // Text of tenant records wasn't changed to 'updated'
|
||||||
|
->toEqual(Post::withoutGlobalScopes()->get()->pluck('text'));
|
||||||
|
|
||||||
|
expect(Comment::all()->pluck('text'))
|
||||||
|
->toContain($post1Comment->text)
|
||||||
|
->not()->toContain($post2Comment->text)
|
||||||
|
->not()->toContain('updated')
|
||||||
|
->toEqual(Comment::withoutGlobalScopes()->get()->pluck('text'));
|
||||||
|
|
||||||
|
expect(Note::all()->pluck('text'))
|
||||||
|
->toContain('foo')
|
||||||
|
->not()->toContain('bar')
|
||||||
|
->not()->toContain('updated')
|
||||||
|
->toEqual(Note::withoutGlobalScopes()->get()->pluck('text'));
|
||||||
|
|
||||||
|
// Try deleting second tenant's records – should have no effect
|
||||||
|
DB::statement("DELETE FROM posts WHERE id = {$post2->id}");
|
||||||
|
DB::statement("DELETE FROM comments WHERE id = {$post2Comment->id}");
|
||||||
|
DB::statement("DELETE FROM notes");
|
||||||
|
|
||||||
|
// Still in tenant1
|
||||||
|
expect(Post::all())->toHaveCount(1); // query with a where targeting another tenant's post had no effect on the current tenant's posts
|
||||||
|
expect(Comment::all())->toHaveCount(1); // query with a where targeting another tenant's post had no effect on the current tenant's posts
|
||||||
|
expect(Note::all())->toHaveCount(0); // query with no WHERE updated the current tenant's comments
|
||||||
|
|
||||||
|
tenancy()->initialize($tenant2);
|
||||||
|
|
||||||
|
// Records weren't deleted by the first tenant
|
||||||
|
expect(Post::count())->toBe(1);
|
||||||
|
expect(Comment::count())->toBe(1);
|
||||||
|
expect(Note::count())->toBe(1);
|
||||||
|
|
||||||
|
// Directly inserting records to other tenant's tables should fail (insufficient privilege error – new row violates row-level security policy)
|
||||||
|
expect(fn () => DB::statement("INSERT INTO posts (text, author_id, category_id, tenant_id) VALUES ('third post', 1, 1, '{$tenant1->getTenantKey()}')"))
|
||||||
|
->toThrow(QueryException::class);
|
||||||
|
|
||||||
|
expect(fn () => DB::statement("INSERT INTO comments (text, post_id) VALUES ('third comment', {$post1->id})"))
|
||||||
|
->toThrow(QueryException::class);
|
||||||
|
|
||||||
|
expect(fn () => DB::statement("INSERT INTO notes (text, comment_id) VALUES ('baz', {$post1Comment->id})"))
|
||||||
|
->toThrow(QueryException::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('table rls manager generates relationship trees with tables related to the tenants table', function (bool $scopeByDefault) {
|
||||||
|
TableRLSManager::$scopeByDefault = $scopeByDefault;
|
||||||
|
|
||||||
|
/** @var TableRLSManager $manager */
|
||||||
|
$manager = app(TableRLSManager::class);
|
||||||
|
|
||||||
|
$expectedTrees = [
|
||||||
|
'authors' => [
|
||||||
|
// Directly related to tenants
|
||||||
|
'tenant_id' => [
|
||||||
|
[
|
||||||
|
[
|
||||||
|
'foreignKey' => 'tenant_id',
|
||||||
|
'foreignTable' => 'tenants',
|
||||||
|
'foreignId' => 'id',
|
||||||
|
'nullable' => false,
|
||||||
|
]
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'comments' => [
|
||||||
|
// Tree starting from the post_id foreign key
|
||||||
|
'post_id' => [
|
||||||
|
[
|
||||||
|
[
|
||||||
|
'foreignKey' => 'post_id',
|
||||||
|
'foreignTable' => 'posts',
|
||||||
|
'foreignId' => 'id',
|
||||||
|
'nullable' => false,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'foreignKey' => 'author_id',
|
||||||
|
'foreignTable' => 'authors',
|
||||||
|
'foreignId' => 'id',
|
||||||
|
'nullable' => false,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'foreignKey' => 'tenant_id',
|
||||||
|
'foreignTable' => 'tenants',
|
||||||
|
'foreignId' => 'id',
|
||||||
|
'nullable' => false,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[
|
||||||
|
'foreignKey' => 'post_id',
|
||||||
|
'foreignTable' => 'posts',
|
||||||
|
'foreignId' => 'id',
|
||||||
|
'nullable' => false,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'foreignKey' => 'tenant_id',
|
||||||
|
'foreignTable' => 'tenants',
|
||||||
|
'foreignId' => 'id',
|
||||||
|
'nullable' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'posts' => [
|
||||||
|
// Category tree gets excluded because the category table is related to the tenant table
|
||||||
|
// only through a column with the 'no-rls' comment
|
||||||
|
'author_id' => [
|
||||||
|
[
|
||||||
|
[
|
||||||
|
'foreignKey' => 'author_id',
|
||||||
|
'foreignTable' => 'authors',
|
||||||
|
'foreignId' => 'id',
|
||||||
|
'nullable' => false,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'foreignKey' => 'tenant_id',
|
||||||
|
'foreignTable' => 'tenants',
|
||||||
|
'foreignId' => 'id',
|
||||||
|
'nullable' => false,
|
||||||
|
]
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'tenant_id' => [
|
||||||
|
[
|
||||||
|
[
|
||||||
|
'foreignKey' => 'tenant_id',
|
||||||
|
'foreignTable' => 'tenants',
|
||||||
|
'foreignId' => 'id',
|
||||||
|
'nullable' => true,
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
],
|
||||||
|
// Articles table is ignored because it's not related to the tenant table in any way
|
||||||
|
// Reactions table is ignored because of the 'no-rls' comment on the comment_id column
|
||||||
|
// Categories table is ignored because of the 'no-rls' comment on the tenant_id column
|
||||||
|
];
|
||||||
|
|
||||||
|
expect($manager->generateTrees())->toEqual($expectedTrees);
|
||||||
|
|
||||||
|
$expectedShortestPaths = [
|
||||||
|
'authors' => [
|
||||||
|
[
|
||||||
|
'foreignKey' => 'tenant_id',
|
||||||
|
'foreignTable' => 'tenants',
|
||||||
|
'foreignId' => 'id',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'posts' => [
|
||||||
|
[
|
||||||
|
'foreignKey' => 'author_id',
|
||||||
|
'foreignTable' => 'authors',
|
||||||
|
'foreignId' => 'id',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'foreignKey' => 'tenant_id',
|
||||||
|
'foreignTable' => 'tenants',
|
||||||
|
'foreignId' => 'id',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'comments' => [
|
||||||
|
[
|
||||||
|
'foreignKey' => 'post_id',
|
||||||
|
'foreignTable' => 'posts',
|
||||||
|
'foreignId' => 'id',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'foreignKey' => 'author_id',
|
||||||
|
'foreignTable' => 'authors',
|
||||||
|
'foreignId' => 'id',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'foreignKey' => 'tenant_id',
|
||||||
|
'foreignTable' => 'tenants',
|
||||||
|
'foreignId' => 'id',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
expect($manager->shortestPaths())->toEqual($expectedShortestPaths);
|
||||||
|
|
||||||
|
// Only related to the tenants table through nullable columns – tenant_id and indirectly through post_id
|
||||||
|
Schema::create('ratings', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->integer('stars')->default(0);
|
||||||
|
|
||||||
|
$table->unsignedBigInteger('post_id')->nullable()->comment('rls');
|
||||||
|
$table->foreign('post_id')->references('id')->on('posts')->onUpdate('cascade')->onDelete('cascade');
|
||||||
|
|
||||||
|
// No 'rls' comment – should get excluded from full trees when using explicit scoping
|
||||||
|
$table->string('tenant_id')->nullable();
|
||||||
|
$table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade');
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The shortest paths should include a path for the ratings table
|
||||||
|
// That leads through tenant_id – when scoping by default is enabled, that's the shortest path
|
||||||
|
// When scoping by default is disabled, the shortest path leads through post_id
|
||||||
|
// This behavior is handled by the manager's generateTrees() method, which is called by shortestPaths()
|
||||||
|
$shortestPaths = $manager->shortestPaths();
|
||||||
|
|
||||||
|
$expectedShortestPath = $scopeByDefault ? [
|
||||||
|
[
|
||||||
|
'foreignKey' => 'tenant_id',
|
||||||
|
'foreignTable' => 'tenants',
|
||||||
|
'foreignId' => 'id',
|
||||||
|
],
|
||||||
|
] : [
|
||||||
|
[
|
||||||
|
'foreignKey' => 'post_id',
|
||||||
|
'foreignTable' => 'posts',
|
||||||
|
'foreignId' => 'id',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'foreignKey' => 'tenant_id',
|
||||||
|
'foreignTable' => 'tenants',
|
||||||
|
'foreignId' => 'id',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
expect($shortestPaths['ratings'])->toBe($expectedShortestPath);
|
||||||
|
|
||||||
|
// Add non-nullable comment_id foreign key
|
||||||
|
Schema::table('ratings', function (Blueprint $table) {
|
||||||
|
$table->foreignId('comment_id')->onUpdate('cascade')->onDelete('cascade')->comment('rls')->constrained('comments');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Non-nullable paths are preferred over nullable paths
|
||||||
|
// The shortest paths should include a path for the ratings table
|
||||||
|
// That leads through comment_id instead of tenant_id
|
||||||
|
$shortestPaths = $manager->shortestPaths();
|
||||||
|
|
||||||
|
expect($shortestPaths['ratings'])->toBe([
|
||||||
|
[
|
||||||
|
'foreignKey' => 'comment_id',
|
||||||
|
'foreignTable' => 'comments',
|
||||||
|
'foreignId' => 'id',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'foreignKey' => 'post_id',
|
||||||
|
'foreignTable' => 'posts',
|
||||||
|
'foreignId' => 'id',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'foreignKey' => 'author_id',
|
||||||
|
'foreignTable' => 'authors',
|
||||||
|
'foreignId' => 'id',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'foreignKey' => 'tenant_id',
|
||||||
|
'foreignTable' => 'tenants',
|
||||||
|
'foreignId' => 'id',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
})->with([true, false]);
|
||||||
|
|
||||||
|
test('table rls manager generates queries correctly', function() {
|
||||||
|
$sessionVariableName = config('tenancy.rls.session_variable_name');
|
||||||
|
|
||||||
|
expect(app(TableRLSManager::class)->generateQueries())->toEqualCanonicalizing([
|
||||||
|
<<<SQL
|
||||||
|
CREATE POLICY authors_rls_policy ON authors USING (
|
||||||
|
tenant_id::text = current_setting('my.current_tenant')
|
||||||
|
);
|
||||||
|
SQL,
|
||||||
|
<<<SQL
|
||||||
|
CREATE POLICY posts_rls_policy ON posts USING (
|
||||||
|
author_id IN (
|
||||||
|
SELECT id
|
||||||
|
FROM authors
|
||||||
|
WHERE tenant_id::text = current_setting('my.current_tenant')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
SQL,
|
||||||
|
<<<SQL
|
||||||
|
CREATE POLICY comments_rls_policy ON comments USING (
|
||||||
|
post_id IN (
|
||||||
|
SELECT id
|
||||||
|
FROM posts
|
||||||
|
WHERE author_id IN (
|
||||||
|
SELECT id
|
||||||
|
FROM authors
|
||||||
|
WHERE tenant_id::text = current_setting('my.current_tenant')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
SQL,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Query generation works when passing custom paths
|
||||||
|
$paths = [
|
||||||
|
'primaries' => [
|
||||||
|
[
|
||||||
|
'foreignKey' => 'tenant_id',
|
||||||
|
'foreignTable' => 'tenants',
|
||||||
|
'foreignId' => 'id',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'secondaries' => [
|
||||||
|
[
|
||||||
|
'foreignKey' => 'primary_id',
|
||||||
|
'foreignTable' => 'primaries',
|
||||||
|
'foreignId' => 'id',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'foreignKey' => 'tenant_id',
|
||||||
|
'foreignTable' => 'tenants',
|
||||||
|
'foreignId' => 'id',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'foo' => [
|
||||||
|
[
|
||||||
|
'foreignKey' => 'secondary_id',
|
||||||
|
'foreignTable' => 'secondaries',
|
||||||
|
'foreignId' => 'id',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'foreignKey' => 'primary_id',
|
||||||
|
'foreignTable' => 'primaries',
|
||||||
|
'foreignId' => 'id',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'foreignKey' => 'tenant_id',
|
||||||
|
'foreignTable' => 'tenants',
|
||||||
|
'foreignId' => 'id',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(app(TableRLSManager::class)->generateQueries($paths))->toContain(
|
||||||
|
<<<SQL
|
||||||
|
CREATE POLICY primaries_rls_policy ON primaries USING (
|
||||||
|
tenant_id::text = current_setting('my.current_tenant')
|
||||||
|
);
|
||||||
|
SQL,
|
||||||
|
<<<SQL
|
||||||
|
CREATE POLICY secondaries_rls_policy ON secondaries USING (
|
||||||
|
primary_id IN (
|
||||||
|
SELECT id
|
||||||
|
FROM primaries
|
||||||
|
WHERE tenant_id::text = current_setting('my.current_tenant')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
SQL,
|
||||||
|
<<<SQL
|
||||||
|
CREATE POLICY foo_rls_policy ON foo USING (
|
||||||
|
secondary_id IN (
|
||||||
|
SELECT id
|
||||||
|
FROM secondaries
|
||||||
|
WHERE primary_id IN (
|
||||||
|
SELECT id
|
||||||
|
FROM primaries
|
||||||
|
WHERE tenant_id::text = current_setting('my.current_tenant')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
SQL,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('table manager throws an exception when encountering a recursive relationship', function() {
|
||||||
|
Schema::create('recursive_posts', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('highlighted_comment_id')->constrained('comments')->nullable()->comment('rls');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('comments', function (Blueprint $table) {
|
||||||
|
$table->foreignId('recursive_post_id')->constrained('recursive_posts')->comment('rls');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fn () => app(TableRLSManager::class)->generateTrees())->toThrow(RecursiveRelationshipException::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
class Post extends Model
|
||||||
|
{
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
public function comments(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Comment::class, 'post_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Comment extends Model
|
||||||
|
{
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
protected $table = 'comments';
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
public function post(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Post::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function notes(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Note::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Note extends Model
|
||||||
|
{
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
public function comment(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Comment::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Category extends Model
|
||||||
|
{
|
||||||
|
protected $guarded = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
class Author extends Model
|
||||||
|
{
|
||||||
|
protected $guarded = [];
|
||||||
|
}
|
||||||
328
tests/RLS/TraitManagerTest.php
Normal file
328
tests/RLS/TraitManagerTest.php
Normal file
|
|
@ -0,0 +1,328 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Stancl\Tenancy\Tests\RLS\Etc\Post;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\QueryException;
|
||||||
|
use Stancl\Tenancy\Events\TenancyEnded;
|
||||||
|
use Stancl\Tenancy\Database\TenantScope;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Stancl\Tenancy\Tests\RLS\Etc\Article;
|
||||||
|
use Stancl\Tenancy\Tests\RLS\Etc\Comment;
|
||||||
|
use Stancl\Tenancy\Database\ParentModelScope;
|
||||||
|
use Stancl\Tenancy\Events\TenancyInitialized;
|
||||||
|
use Stancl\Tenancy\Listeners\BootstrapTenancy;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Stancl\Tenancy\Listeners\RevertToCentralContext;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Stancl\Tenancy\Database\Concerns\BelongsToTenant;
|
||||||
|
use Stancl\Tenancy\Commands\CreateUserWithRLSPolicies;
|
||||||
|
use Stancl\Tenancy\RLS\PolicyManagers\TraitRLSManager;
|
||||||
|
use Stancl\Tenancy\Bootstrappers\PostgresRLSBootstrapper;
|
||||||
|
use Stancl\Tenancy\Database\Concerns\BelongsToPrimaryModel;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
TraitRLSManager::$implicitRLS = true;
|
||||||
|
TraitRLSManager::$modelDirectories = [__DIR__ . '/Etc'];
|
||||||
|
TraitRLSManager::$excludedModels = [Article::class];
|
||||||
|
|
||||||
|
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
|
||||||
|
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
|
||||||
|
|
||||||
|
DB::purge($centralConnection = config('tenancy.database.central_connection'));
|
||||||
|
config(['database.connections.' . $centralConnection => config('database.connections.pgsql')]);
|
||||||
|
config(['tenancy.models.tenant_key_column' => 'tenant_id']);
|
||||||
|
config(['tenancy.models.tenant' => Tenant::class]);
|
||||||
|
config(['tenancy.rls.manager' => TraitRLSManager::class]);
|
||||||
|
config(['tenancy.rls.user' => [
|
||||||
|
'username' => 'username',
|
||||||
|
'password' => 'password',
|
||||||
|
]]);
|
||||||
|
|
||||||
|
config(['tenancy.bootstrappers' => [PostgresRLSBootstrapper::class]]);
|
||||||
|
|
||||||
|
pest()->artisan('migrate:fresh', [
|
||||||
|
'--force' => true,
|
||||||
|
'--path' => __DIR__ . '/../../assets/migrations',
|
||||||
|
'--realpath' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Schema::create('posts', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('text');
|
||||||
|
$table->string('tenant_id');
|
||||||
|
$table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade');
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('comments', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('text');
|
||||||
|
$table->foreignId('post_id')->constrained('posts')->onUpdate('cascade')->onDelete('cascade');
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Exists to check that the manager doesn't generate queries for models excluded from model discovery
|
||||||
|
Schema::create('articles', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('text');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('correct rls policies get created with the correct hash using trait manager', function () {
|
||||||
|
$manager = app(TraitRLSManager::class);
|
||||||
|
|
||||||
|
// Tables that are directly or indirectly related to the tenant
|
||||||
|
$tables = collect($manager->getModels())
|
||||||
|
->filter(fn (Model $model) => $manager->modelBelongsToTenant($model) || $manager->modelBelongsToTenantIndirectly($model))
|
||||||
|
->map(fn (Model $model) => $model->getTable())
|
||||||
|
->values()
|
||||||
|
->unique()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$getRLSPolicies = fn () => DB::select('SELECT policyname, tablename FROM pg_policies');
|
||||||
|
$getRLSTables = fn () => collect($tables)->map(fn ($table) => DB::select('SELECT relname, relforcerowsecurity FROM pg_class WHERE oid = ?::regclass', [$table]))->collapse();
|
||||||
|
|
||||||
|
expect($getRLSPolicies())->toHaveCount(0);
|
||||||
|
|
||||||
|
pest()->artisan('tenants:rls');
|
||||||
|
|
||||||
|
// Check if all tables related to the tenant have RLS policies
|
||||||
|
expect($policies = $getRLSPolicies())->toHaveCount(count($tables));
|
||||||
|
expect($rlsTables = $getRLSTables())->toHaveCount(count($tables));
|
||||||
|
|
||||||
|
foreach ($rlsTables as $table) {
|
||||||
|
expect($tables)->toContain($table->relname);
|
||||||
|
expect($table->relforcerowsecurity)->toBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that the policies get suffixed with the correct hash
|
||||||
|
$queries = $manager->generateQueries();
|
||||||
|
expect(array_keys($queries))->toEqualCanonicalizing($tables);
|
||||||
|
expect(array_keys($queries))->not()->toContain('articles');
|
||||||
|
|
||||||
|
/** @var CreateUserWithRLSPolicies $policyCreationCommand */
|
||||||
|
$policyCreationCommand = app(CreateUserWithRLSPolicies::class);
|
||||||
|
|
||||||
|
foreach ($queries as $table => $query) {
|
||||||
|
$policy = collect($policies)->filter(fn (object $policy) => $policy->tablename === $table)->first();
|
||||||
|
$hash = $policyCreationCommand->hashPolicy($query)[0];
|
||||||
|
$policyNameWithHash = "{$table}_rls_policy_{$hash}";
|
||||||
|
|
||||||
|
expect($tables)->toContain($policy->tablename);
|
||||||
|
expect($policy->policyname)->toBe($policyNameWithHash);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('global scope is not applied when using rls with single db traits', function () {
|
||||||
|
// The global scopes (TenantScope and ParentModelScope) are added to models
|
||||||
|
// that are using the single DB traits (BelongsToTenant and BelongsToPrimaryModel)
|
||||||
|
// if TraitRLSManager::$implicitRLS is false and the model does not implement RLSModel
|
||||||
|
TraitRLSManager::$implicitRLS = false;
|
||||||
|
|
||||||
|
// Post model uses BelongsToTenant
|
||||||
|
// Comment uses BelongsToPrimaryModel
|
||||||
|
// Both models implement RLSModel, so they shouldn't have the global scope
|
||||||
|
expect(Post::make()->hasGlobalScope(TenantScope::class))->toBeFalse();
|
||||||
|
expect(Comment::make()->hasGlobalScope(ParentModelScope::class))->toBeFalse();
|
||||||
|
|
||||||
|
// These models DO NOT implement RLSModel
|
||||||
|
expect(NonRLSPost::make()->hasGlobalScope(TenantScope::class))->toBeTrue();
|
||||||
|
expect(NonRLSComment::make()->hasGlobalScope(ParentModelScope::class))->toBeTrue();
|
||||||
|
|
||||||
|
TraitRLSManager::$implicitRLS = true;
|
||||||
|
NonRLSPost::clearBootedModels();
|
||||||
|
NonRLSComment::clearBootedModels();
|
||||||
|
|
||||||
|
// Both NonRLSPost and NonRLSComment use the single DB traits, but don't implement RLSModel
|
||||||
|
// The models still shouldn't have the global scope because RLS is enabled implicitly
|
||||||
|
expect(NonRLSPost::make()->hasGlobalScope(TenantScope::class))->toBeFalse();
|
||||||
|
expect(NonRLSComment::make()->hasGlobalScope(ParentModelScope::class))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('queries are correctly scoped using RLS with trait rls manager', function (bool $implicitRLS) {
|
||||||
|
TraitRLSManager::$implicitRLS = $implicitRLS;
|
||||||
|
|
||||||
|
$postModel = $implicitRLS ? NonRLSPost::class : Post::class;
|
||||||
|
$commentModel = $implicitRLS ? NonRLSComment::class : Comment::class;
|
||||||
|
|
||||||
|
// Create RLS policies for tables and the tenant user
|
||||||
|
pest()->artisan('tenants:rls');
|
||||||
|
|
||||||
|
// Create two tenants
|
||||||
|
$tenant1 = Tenant::create();
|
||||||
|
$tenant2 = Tenant::create();
|
||||||
|
|
||||||
|
// Create posts and comments for both tenants
|
||||||
|
tenancy()->initialize($tenant1);
|
||||||
|
|
||||||
|
$post1 = $postModel::create([
|
||||||
|
'text' => 'first post',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$post1Comment = $commentModel::create(['text' => 'first comment', 'post_id' => $post1->id]);
|
||||||
|
|
||||||
|
tenancy()->initialize($tenant2);
|
||||||
|
|
||||||
|
$post2 = $postModel::create([
|
||||||
|
'text' => 'second post',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$post2Comment = $commentModel::create(['text' => 'second comment', 'post_id' => $post2->id]);
|
||||||
|
|
||||||
|
tenancy()->initialize($tenant1);
|
||||||
|
|
||||||
|
expect($postModel::all()->pluck('text'))
|
||||||
|
->toHaveCount(1)
|
||||||
|
->toContain($post1->text)
|
||||||
|
->not()->toContain($post2->text)
|
||||||
|
->toEqual($postModel::withoutGlobalScopes()->get()->pluck('text'));
|
||||||
|
|
||||||
|
expect($commentModel::all()->pluck('text'))
|
||||||
|
->toHaveCount(1)
|
||||||
|
->toContain($post1Comment->text)
|
||||||
|
->not()->toContain($post2Comment->text)
|
||||||
|
->toEqual($commentModel::withoutGlobalScopes()->get()->pluck('text'));
|
||||||
|
|
||||||
|
tenancy()->end();
|
||||||
|
|
||||||
|
expect($postModel::all()->pluck('text'))
|
||||||
|
->toHaveCount(2)
|
||||||
|
->toContain($post1->text)
|
||||||
|
->toContain($post2->text);
|
||||||
|
|
||||||
|
expect($commentModel::all()->pluck('text'))
|
||||||
|
->toHaveCount(2)
|
||||||
|
->toContain($post1Comment->text)
|
||||||
|
->toContain($post2Comment->text);
|
||||||
|
|
||||||
|
tenancy()->initialize($tenant2);
|
||||||
|
|
||||||
|
expect($postModel::all()->pluck('text'))
|
||||||
|
->toHaveCount(1)
|
||||||
|
->toContain($post2->text)
|
||||||
|
->not()->toContain($post1->text)
|
||||||
|
->toEqual($postModel::withoutGlobalScopes()->get()->pluck('text'));
|
||||||
|
|
||||||
|
expect($commentModel::all()->pluck('text'))
|
||||||
|
->toHaveCount(1)
|
||||||
|
->toContain($post2Comment->text)
|
||||||
|
->not()->toContain($post1Comment->text)
|
||||||
|
->toEqual($commentModel::withoutGlobalScopes()->get()->pluck('text'));
|
||||||
|
|
||||||
|
// Test that RLS policies protect tenants from other tenant's direct queries
|
||||||
|
DB::statement("UPDATE posts SET text = 'updated' WHERE id = {$post1->id}"); // should have no effect
|
||||||
|
DB::statement("UPDATE comments SET text = 'updated'"); // should only update the current tenant's comments
|
||||||
|
|
||||||
|
// Still in tenant2
|
||||||
|
expect($commentModel::all()->pluck('text'))
|
||||||
|
->toContain('updated'); // query with no WHERE updated the current tenant's comments
|
||||||
|
expect($postModel::all()->pluck('text'))
|
||||||
|
->toContain('second post'); // query with a where targeting another tenant's post had no effect on the current tenant's posts
|
||||||
|
|
||||||
|
tenancy()->initialize($tenant1);
|
||||||
|
|
||||||
|
expect($postModel::all()->pluck('text'))
|
||||||
|
->toContain($post1->text)
|
||||||
|
->not()->toContain($post2->text)
|
||||||
|
->not()->toContain('updated') // Text of tenant records was NOT changed to 'updated'
|
||||||
|
->toEqual($postModel::withoutGlobalScopes()->get()->pluck('text'));
|
||||||
|
|
||||||
|
expect($commentModel::all()->pluck('text'))
|
||||||
|
->toContain($post1Comment->text)
|
||||||
|
->not()->toContain($post2Comment->text)
|
||||||
|
->not()->toContain('updated') // No change to posts either
|
||||||
|
->toEqual($commentModel::withoutGlobalScopes()->get()->pluck('text'));
|
||||||
|
|
||||||
|
// Try deleting second tenant's records – should have no effect
|
||||||
|
DB::statement("DELETE FROM posts WHERE id = {$post2->id}");
|
||||||
|
DB::statement("DELETE FROM comments");
|
||||||
|
|
||||||
|
// Still in tenant1
|
||||||
|
expect($postModel::all())->toHaveCount(1); // query with a where targeting another tenant's post had no effect on the current tenant's posts
|
||||||
|
expect($commentModel::all())->toHaveCount(0); // query with no WHERE updated the current tenant's comments
|
||||||
|
|
||||||
|
tenancy()->initialize($tenant2);
|
||||||
|
|
||||||
|
// Records weren't deleted by the first tenant
|
||||||
|
expect($postModel::count())->toBe(1);
|
||||||
|
expect($commentModel::count())->toBe(1);
|
||||||
|
|
||||||
|
// Directly inserting records to other tenant's tables should fail (insufficient privilege error – new row violates row-level security policy)
|
||||||
|
expect(fn () => DB::statement("INSERT INTO posts (text, tenant_id) VALUES ('third post', '{$tenant1->getTenantKey()}')"))
|
||||||
|
->toThrow(QueryException::class);
|
||||||
|
|
||||||
|
expect(fn () => DB::statement("INSERT INTO comments (text, post_id) VALUES ('third comment', {$post1->id})"))
|
||||||
|
->toThrow(QueryException::class);
|
||||||
|
})->with([
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
]);
|
||||||
|
|
||||||
|
test('trait rls manager generates queries correctly', function() {
|
||||||
|
/** @var TraitRLSManager $manager */
|
||||||
|
$manager = app(TraitRLSManager::class);
|
||||||
|
|
||||||
|
// Three tables related to tenants – posts (directly), comments (indirectly)
|
||||||
|
expect($manager->generateQueries())->toContain(
|
||||||
|
<<<SQL
|
||||||
|
CREATE POLICY posts_rls_policy ON posts USING (
|
||||||
|
tenant_id::text = current_setting('my.current_tenant')
|
||||||
|
);
|
||||||
|
SQL,
|
||||||
|
<<<SQL
|
||||||
|
CREATE POLICY comments_rls_policy ON comments USING (
|
||||||
|
post_id IN (
|
||||||
|
SELECT id
|
||||||
|
FROM posts
|
||||||
|
WHERE tenant_id::text = current_setting('my.current_tenant')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
SQL,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
class NonRLSPost extends Model
|
||||||
|
{
|
||||||
|
use BelongsToTenant;
|
||||||
|
|
||||||
|
public $table = 'posts';
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
public function comments(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(NonRLSComment::class, 'post_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class NonRLSComment extends Model
|
||||||
|
{
|
||||||
|
use BelongsToPrimaryModel;
|
||||||
|
|
||||||
|
public $table = 'comments';
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
public function getRelationshipToPrimaryModel(): string
|
||||||
|
{
|
||||||
|
return 'post';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function post(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(NonRLSPost::class, 'post_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ use Illuminate\Database\QueryException;
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Stancl\Tenancy\Database\Models\Tenant;
|
use Stancl\Tenancy\Database\Models\Tenant;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Stancl\Tenancy\Database\Concerns\BelongsToTenant;
|
use Stancl\Tenancy\Database\Concerns\BelongsToTenant;
|
||||||
use Stancl\Tenancy\Database\Concerns\BelongsToPrimaryModel;
|
use Stancl\Tenancy\Database\Concerns\BelongsToPrimaryModel;
|
||||||
use Stancl\Tenancy\Database\Concerns\HasScopedValidationRules;
|
use Stancl\Tenancy\Database\Concerns\HasScopedValidationRules;
|
||||||
|
|
@ -40,12 +41,12 @@ test('primary models are scoped to the current tenant', function () {
|
||||||
'id' => 'acme',
|
'id' => 'acme',
|
||||||
]));
|
]));
|
||||||
|
|
||||||
$post = Post::create(['text' => 'Foo']);
|
$post = SingleDatabasePost::create(['text' => 'Foo']);
|
||||||
|
|
||||||
expect($post->tenant_id)->toBe('acme');
|
expect($post->tenant_id)->toBe('acme');
|
||||||
expect($post->tenant->id)->toBe('acme');
|
expect($post->tenant->id)->toBe('acme');
|
||||||
|
|
||||||
$post = Post::first();
|
$post = SingleDatabasePost::first();
|
||||||
|
|
||||||
expect($post->tenant_id)->toBe('acme');
|
expect($post->tenant_id)->toBe('acme');
|
||||||
expect($post->tenant->id)->toBe('acme');
|
expect($post->tenant->id)->toBe('acme');
|
||||||
|
|
@ -56,12 +57,12 @@ test('primary models are scoped to the current tenant', function () {
|
||||||
'id' => 'foobar',
|
'id' => 'foobar',
|
||||||
]));
|
]));
|
||||||
|
|
||||||
$post = Post::create(['text' => 'Bar']);
|
$post = SingleDatabasePost::create(['text' => 'Bar']);
|
||||||
|
|
||||||
expect($post->tenant_id)->toBe('foobar');
|
expect($post->tenant_id)->toBe('foobar');
|
||||||
expect($post->tenant->id)->toBe('foobar');
|
expect($post->tenant->id)->toBe('foobar');
|
||||||
|
|
||||||
$post = Post::first();
|
$post = SingleDatabasePost::first();
|
||||||
|
|
||||||
expect($post->tenant_id)->toBe('foobar');
|
expect($post->tenant_id)->toBe('foobar');
|
||||||
expect($post->tenant->id)->toBe('foobar');
|
expect($post->tenant->id)->toBe('foobar');
|
||||||
|
|
@ -71,17 +72,17 @@ test('primary models are scoped to the current tenant', function () {
|
||||||
|
|
||||||
tenancy()->initialize($acme);
|
tenancy()->initialize($acme);
|
||||||
|
|
||||||
$post = Post::first();
|
$post = SingleDatabasePost::first();
|
||||||
expect($post->tenant_id)->toBe('acme');
|
expect($post->tenant_id)->toBe('acme');
|
||||||
expect($post->tenant->id)->toBe('acme');
|
expect($post->tenant->id)->toBe('acme');
|
||||||
|
|
||||||
// Assert foobar models are inaccessible in acme context
|
// Assert foobar models are inaccessible in acme context
|
||||||
expect(Post::count())->toBe(1);
|
expect(SingleDatabasePost::count())->toBe(1);
|
||||||
|
|
||||||
// Primary models are not scoped in the central context
|
// Primary models are not scoped in the central context
|
||||||
tenancy()->end();
|
tenancy()->end();
|
||||||
|
|
||||||
expect(Post::count())->toBe(2);
|
expect(SingleDatabasePost::count())->toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('secondary models ARE scoped to the current tenant when accessed directly and parent relationship trait is used', function () {
|
test('secondary models ARE scoped to the current tenant when accessed directly and parent relationship trait is used', function () {
|
||||||
|
|
@ -90,10 +91,10 @@ test('secondary models ARE scoped to the current tenant when accessed directly a
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$acme->run(function () {
|
$acme->run(function () {
|
||||||
$post = Post::create(['text' => 'Foo']);
|
$post = SingleDatabasePost::create(['text' => 'Foo']);
|
||||||
$post->scoped_comments()->create(['text' => 'Comment Text']);
|
$post->comments()->create(['text' => 'Comment Text']);
|
||||||
|
|
||||||
expect(Post::count())->toBe(1);
|
expect(SingleDatabasePost::count())->toBe(1);
|
||||||
expect(ScopedComment::count())->toBe(1);
|
expect(ScopedComment::count())->toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -102,14 +103,16 @@ test('secondary models ARE scoped to the current tenant when accessed directly a
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$foobar->run(function () {
|
$foobar->run(function () {
|
||||||
expect(Post::count())->toBe(0);
|
expect(SingleDatabasePost::count())->toBe(0);
|
||||||
expect(ScopedComment::count())->toBe(0);
|
expect(ScopedComment::count())->toBe(0);
|
||||||
|
|
||||||
$post = Post::create(['text' => 'Bar']);
|
$post = SingleDatabasePost::create(['text' => 'Bar']);
|
||||||
$post->scoped_comments()->create(['text' => 'Comment Text 2']);
|
$post->comments()->create(['text' => 'Comment Text 2']);
|
||||||
|
|
||||||
expect(Post::count())->toBe(1);
|
expect(SingleDatabasePost::count())->toBe(1);
|
||||||
expect(ScopedComment::count())->toBe(1);
|
expect(ScopedComment::count())->toBe(1);
|
||||||
|
// whereas...
|
||||||
|
expect(Comment::count())->toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Global context
|
// Global context
|
||||||
|
|
@ -123,7 +126,7 @@ test('secondary models are scoped correctly', function () {
|
||||||
'id' => 'acme',
|
'id' => 'acme',
|
||||||
]));
|
]));
|
||||||
|
|
||||||
$post = Post::create(['text' => 'Foo']);
|
$post = SingleDatabasePost::create(['text' => 'Foo']);
|
||||||
$post->comments()->create(['text' => 'Comment text']);
|
$post->comments()->create(['text' => 'Comment text']);
|
||||||
|
|
||||||
// ================
|
// ================
|
||||||
|
|
@ -132,24 +135,24 @@ test('secondary models are scoped correctly', function () {
|
||||||
'id' => 'foobar',
|
'id' => 'foobar',
|
||||||
]));
|
]));
|
||||||
|
|
||||||
$post = Post::create(['text' => 'Bar']);
|
$post = SingleDatabasePost::create(['text' => 'Bar']);
|
||||||
$post->comments()->create(['text' => 'Comment text 2']);
|
$post->comments()->create(['text' => 'Comment text 2']);
|
||||||
|
|
||||||
// ================
|
// ================
|
||||||
// acme context again
|
// acme context again
|
||||||
tenancy()->initialize($acme);
|
tenancy()->initialize($acme);
|
||||||
expect(Post::count())->toBe(1);
|
expect(SingleDatabasePost::count())->toBe(1);
|
||||||
expect(Post::first()->comments->count())->toBe(1);
|
expect(SingleDatabasePost::first()->comments->count())->toBe(1);
|
||||||
|
|
||||||
// Secondary models are not scoped to the current tenant when accessed directly
|
// Secondary models are not scoped to the current tenant when accessed directly
|
||||||
expect(tenant('id'))->toBe('acme');
|
expect(tenant('id'))->toBe('acme');
|
||||||
|
|
||||||
expect(Comment::count())->toBe(2);
|
expect(BaseComment::count())->toBe(2);
|
||||||
|
|
||||||
// secondary models are not scoped in the central context
|
// secondary models are not scoped in the central context
|
||||||
tenancy()->end();
|
tenancy()->end();
|
||||||
|
|
||||||
expect(Comment::count())->toBe(2);
|
expect(BaseComment::count())->toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('global models are not scoped at all', function () {
|
test('global models are not scoped at all', function () {
|
||||||
|
|
@ -180,7 +183,7 @@ test('tenant id and relationship is auto added when creating primary resources i
|
||||||
'id' => 'acme',
|
'id' => 'acme',
|
||||||
]));
|
]));
|
||||||
|
|
||||||
$post = Post::create(['text' => 'Foo']);
|
$post = SingleDatabasePost::create(['text' => 'Foo']);
|
||||||
|
|
||||||
expect($post->tenant_id)->toBe('acme');
|
expect($post->tenant_id)->toBe('acme');
|
||||||
expect($post->relationLoaded('tenant'))->toBeTrue();
|
expect($post->relationLoaded('tenant'))->toBeTrue();
|
||||||
|
|
@ -191,7 +194,7 @@ test('tenant id and relationship is auto added when creating primary resources i
|
||||||
test('tenant id is not auto added when creating primary resources in central context', function () {
|
test('tenant id is not auto added when creating primary resources in central context', function () {
|
||||||
pest()->expectException(QueryException::class);
|
pest()->expectException(QueryException::class);
|
||||||
|
|
||||||
Post::create(['text' => 'Foo']);
|
SingleDatabasePost::create(['text' => 'Foo']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('tenant id column name can be customized', function () {
|
test('tenant id column name can be customized', function () {
|
||||||
|
|
@ -214,7 +217,7 @@ test('tenant id column name can be customized', function () {
|
||||||
|
|
||||||
tenancy()->initialize($acme);
|
tenancy()->initialize($acme);
|
||||||
|
|
||||||
$post = Post::create(['text' => 'Foo']);
|
$post = SingleDatabasePost::create(['text' => 'Foo']);
|
||||||
|
|
||||||
expect($post->team_id)->toBe('acme');
|
expect($post->team_id)->toBe('acme');
|
||||||
|
|
||||||
|
|
@ -224,11 +227,11 @@ test('tenant id column name can be customized', function () {
|
||||||
'id' => 'foobar',
|
'id' => 'foobar',
|
||||||
]));
|
]));
|
||||||
|
|
||||||
$post = Post::create(['text' => 'Bar']);
|
$post = SingleDatabasePost::create(['text' => 'Bar']);
|
||||||
|
|
||||||
expect($post->team_id)->toBe('foobar');
|
expect($post->team_id)->toBe('foobar');
|
||||||
|
|
||||||
$post = Post::first();
|
$post = SingleDatabasePost::first();
|
||||||
|
|
||||||
expect($post->team_id)->toBe('foobar');
|
expect($post->team_id)->toBe('foobar');
|
||||||
|
|
||||||
|
|
@ -237,11 +240,11 @@ test('tenant id column name can be customized', function () {
|
||||||
|
|
||||||
tenancy()->initialize($acme);
|
tenancy()->initialize($acme);
|
||||||
|
|
||||||
$post = Post::first();
|
$post = SingleDatabasePost::first();
|
||||||
expect($post->team_id)->toBe('acme');
|
expect($post->team_id)->toBe('acme');
|
||||||
|
|
||||||
// Assert foobar models are inaccessible in acme context
|
// Assert foobar models are inaccessible in acme context
|
||||||
expect(Post::count())->toBe(1);
|
expect(SingleDatabasePost::count())->toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the model returned by the tenant helper has unique and exists validation rules', function () {
|
test('the model returned by the tenant helper has unique and exists validation rules', function () {
|
||||||
|
|
@ -254,7 +257,7 @@ test('the model returned by the tenant helper has unique and exists validation r
|
||||||
'id' => 'acme',
|
'id' => 'acme',
|
||||||
]));
|
]));
|
||||||
|
|
||||||
Post::create(['text' => 'Foo', 'slug' => 'foo']);
|
SingleDatabasePost::create(['text' => 'Foo', 'slug' => 'foo']);
|
||||||
$data = ['text' => 'Foo 2', 'slug' => 'foo'];
|
$data = ['text' => 'Foo 2', 'slug' => 'foo'];
|
||||||
|
|
||||||
$uniqueFails = Validator::make($data, [
|
$uniqueFails = Validator::make($data, [
|
||||||
|
|
@ -285,38 +288,40 @@ class SingleDatabaseTenant extends Tenant
|
||||||
use HasScopedValidationRules;
|
use HasScopedValidationRules;
|
||||||
}
|
}
|
||||||
|
|
||||||
class Post extends Model
|
class SingleDatabasePost extends Model
|
||||||
{
|
{
|
||||||
use BelongsToTenant;
|
use BelongsToTenant;
|
||||||
|
|
||||||
protected $guarded = [];
|
protected $guarded = [];
|
||||||
|
|
||||||
|
public $table = 'posts';
|
||||||
|
|
||||||
public $timestamps = false;
|
public $timestamps = false;
|
||||||
|
|
||||||
public function comments()
|
public function comments()
|
||||||
{
|
{
|
||||||
return $this->hasMany(Comment::class);
|
return $this->hasMany(BaseComment::class, 'post_id');
|
||||||
}
|
|
||||||
|
|
||||||
public function scoped_comments()
|
|
||||||
{
|
|
||||||
return $this->hasMany(Comment::class);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class Comment extends Model
|
class BaseComment extends Model
|
||||||
{
|
{
|
||||||
protected $guarded = [];
|
protected $guarded = [];
|
||||||
|
|
||||||
|
protected $table = 'comments';
|
||||||
|
|
||||||
public $timestamps = false;
|
public $timestamps = false;
|
||||||
|
|
||||||
public function post()
|
public function post()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Post::class);
|
return $this->belongsTo(SingleDatabasePost::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ScopedComment extends Comment
|
// accessed via the comments() relationship (same table as BaseComment)
|
||||||
|
// however, when used directly, the model scopes queries to the current tenant
|
||||||
|
// unlike BaseComment
|
||||||
|
class ScopedComment extends BaseComment
|
||||||
{
|
{
|
||||||
use BelongsToPrimaryModel;
|
use BelongsToPrimaryModel;
|
||||||
|
|
||||||
|
|
@ -326,6 +331,11 @@ class ScopedComment extends Comment
|
||||||
{
|
{
|
||||||
return 'post';
|
return 'post';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function post(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(SingleDatabasePost::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class GlobalResource extends Model
|
class GlobalResource extends Model
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ use Stancl\Tenancy\Database\Exceptions\TenantDatabaseAlreadyExistsException;
|
||||||
use Stancl\Tenancy\Database\TenantDatabaseManagers\PostgreSQLDatabaseManager;
|
use Stancl\Tenancy\Database\TenantDatabaseManagers\PostgreSQLDatabaseManager;
|
||||||
use Stancl\Tenancy\Database\TenantDatabaseManagers\MicrosoftSQLDatabaseManager;
|
use Stancl\Tenancy\Database\TenantDatabaseManagers\MicrosoftSQLDatabaseManager;
|
||||||
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledMySQLDatabaseManager;
|
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledMySQLDatabaseManager;
|
||||||
|
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQLSchemaManager;
|
||||||
|
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQLDatabaseManager;
|
||||||
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledMicrosoftSQLServerDatabaseManager;
|
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledMicrosoftSQLServerDatabaseManager;
|
||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
|
|
@ -302,7 +304,7 @@ test('database credentials can be provided to PermissionControlledMySQLDatabaseM
|
||||||
|
|
||||||
// Create a new random database user with privileges to use with mysql2 connection
|
// Create a new random database user with privileges to use with mysql2 connection
|
||||||
$username = 'dbuser' . Str::random(4);
|
$username = 'dbuser' . Str::random(4);
|
||||||
$password = Str::random('8');
|
$password = Str::random(8);
|
||||||
$mysql2DB = DB::connection('mysql2');
|
$mysql2DB = DB::connection('mysql2');
|
||||||
$mysql2DB->statement("CREATE USER `{$username}`@`%` IDENTIFIED BY '{$password}';");
|
$mysql2DB->statement("CREATE USER `{$username}`@`%` IDENTIFIED BY '{$password}';");
|
||||||
$mysql2DB->statement("GRANT ALL PRIVILEGES ON *.* TO `{$username}`@`%` WITH GRANT OPTION;");
|
$mysql2DB->statement("GRANT ALL PRIVILEGES ON *.* TO `{$username}`@`%` WITH GRANT OPTION;");
|
||||||
|
|
@ -347,7 +349,7 @@ test('tenant database can be created by using the username and password from ten
|
||||||
|
|
||||||
// Create a new random database user with privileges to use with `mysql` connection
|
// Create a new random database user with privileges to use with `mysql` connection
|
||||||
$username = 'dbuser' . Str::random(4);
|
$username = 'dbuser' . Str::random(4);
|
||||||
$password = Str::random('8');
|
$password = Str::random(8);
|
||||||
$mysqlDB = DB::connection('mysql');
|
$mysqlDB = DB::connection('mysql');
|
||||||
$mysqlDB->statement("CREATE USER `{$username}`@`%` IDENTIFIED BY '{$password}';");
|
$mysqlDB->statement("CREATE USER `{$username}`@`%` IDENTIFIED BY '{$password}';");
|
||||||
$mysqlDB->statement("GRANT ALL PRIVILEGES ON *.* TO `{$username}`@`%` WITH GRANT OPTION;");
|
$mysqlDB->statement("GRANT ALL PRIVILEGES ON *.* TO `{$username}`@`%` WITH GRANT OPTION;");
|
||||||
|
|
@ -461,6 +463,7 @@ test('partial tenant connection templates get merged into the central connection
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$name = 'foo' . Str::random(8);
|
$name = 'foo' . Str::random(8);
|
||||||
|
|
||||||
$tenant = Tenant::create([
|
$tenant = Tenant::create([
|
||||||
'tenancy_db_name' => $name,
|
'tenancy_db_name' => $name,
|
||||||
]);
|
]);
|
||||||
|
|
@ -479,6 +482,8 @@ dataset('database_managers', [
|
||||||
['sqlite', SQLiteDatabaseManager::class],
|
['sqlite', SQLiteDatabaseManager::class],
|
||||||
['pgsql', PostgreSQLDatabaseManager::class],
|
['pgsql', PostgreSQLDatabaseManager::class],
|
||||||
['pgsql', PostgreSQLSchemaManager::class],
|
['pgsql', PostgreSQLSchemaManager::class],
|
||||||
|
['pgsql', PermissionControlledPostgreSQLDatabaseManager::class],
|
||||||
|
['pgsql', PermissionControlledPostgreSQLSchemaManager::class],
|
||||||
['sqlsrv', MicrosoftSQLDatabaseManager::class],
|
['sqlsrv', MicrosoftSQLDatabaseManager::class],
|
||||||
['sqlsrv', PermissionControlledMicrosoftSQLServerDatabaseManager::class]
|
['sqlsrv', PermissionControlledMicrosoftSQLServerDatabaseManager::class]
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -4,23 +4,24 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Stancl\Tenancy\Tests;
|
namespace Stancl\Tenancy\Tests;
|
||||||
|
|
||||||
use Aws\DynamoDb\DynamoDbClient;
|
|
||||||
use PDO;
|
use PDO;
|
||||||
use Dotenv\Dotenv;
|
use Dotenv\Dotenv;
|
||||||
|
use Aws\DynamoDb\DynamoDbClient;
|
||||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||||
use Illuminate\Support\Facades\Redis;
|
use Illuminate\Support\Facades\Redis;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
use Stancl\Tenancy\Bootstrappers\BroadcastChannelPrefixBootstrapper;
|
|
||||||
use Stancl\Tenancy\Facades\GlobalCache;
|
use Stancl\Tenancy\Facades\GlobalCache;
|
||||||
use Stancl\Tenancy\TenancyServiceProvider;
|
use Stancl\Tenancy\TenancyServiceProvider;
|
||||||
use Stancl\Tenancy\Facades\Tenancy as TenancyFacade;
|
use Stancl\Tenancy\Facades\Tenancy as TenancyFacade;
|
||||||
use Stancl\Tenancy\Bootstrappers\RootUrlBootstrapper;
|
use Stancl\Tenancy\Bootstrappers\RootUrlBootstrapper;
|
||||||
use Stancl\Tenancy\Bootstrappers\MailConfigBootstrapper;
|
use Stancl\Tenancy\Bootstrappers\MailConfigBootstrapper;
|
||||||
|
use Stancl\Tenancy\Bootstrappers\PostgresRLSBootstrapper;
|
||||||
|
use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper;
|
||||||
use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper;
|
use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper;
|
||||||
use Stancl\Tenancy\Bootstrappers\UrlGeneratorBootstrapper;
|
use Stancl\Tenancy\Bootstrappers\UrlGeneratorBootstrapper;
|
||||||
use Stancl\Tenancy\Bootstrappers\BroadcastingConfigBootstrapper;
|
use Stancl\Tenancy\Bootstrappers\BroadcastingConfigBootstrapper;
|
||||||
use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper;
|
use Stancl\Tenancy\Bootstrappers\BroadcastChannelPrefixBootstrapper;
|
||||||
|
|
||||||
abstract class TestCase extends \Orchestra\Testbench\TestCase
|
abstract class TestCase extends \Orchestra\Testbench\TestCase
|
||||||
{
|
{
|
||||||
|
|
@ -77,6 +78,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
|
||||||
]);
|
]);
|
||||||
|
|
||||||
file_put_contents(database_path('central.sqlite'), '');
|
file_put_contents(database_path('central.sqlite'), '');
|
||||||
|
|
||||||
pest()->artisan('migrate:fresh', [
|
pest()->artisan('migrate:fresh', [
|
||||||
'--force' => true,
|
'--force' => true,
|
||||||
'--path' => __DIR__ . '/../assets/migrations',
|
'--path' => __DIR__ . '/../assets/migrations',
|
||||||
|
|
@ -177,6 +179,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
|
||||||
$app->singleton(CacheTenancyBootstrapper::class); // todo@samuel use proper approach eg config for singleton registration
|
$app->singleton(CacheTenancyBootstrapper::class); // todo@samuel use proper approach eg config for singleton registration
|
||||||
$app->singleton(BroadcastingConfigBootstrapper::class);
|
$app->singleton(BroadcastingConfigBootstrapper::class);
|
||||||
$app->singleton(BroadcastChannelPrefixBootstrapper::class);
|
$app->singleton(BroadcastChannelPrefixBootstrapper::class);
|
||||||
|
$app->singleton(PostgresRLSBootstrapper::class);
|
||||||
$app->singleton(MailConfigBootstrapper::class);
|
$app->singleton(MailConfigBootstrapper::class);
|
||||||
$app->singleton(RootUrlBootstrapper::class);
|
$app->singleton(RootUrlBootstrapper::class);
|
||||||
$app->singleton(UrlGeneratorBootstrapper::class);
|
$app->singleton(UrlGeneratorBootstrapper::class);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||||
|
|
||||||
use Stancl\Tenancy\Tenancy;
|
use Stancl\Tenancy\Tenancy;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Routing\Route;
|
|
||||||
use Stancl\Tenancy\Enums\RouteMode;
|
use Stancl\Tenancy\Enums\RouteMode;
|
||||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||||
use Illuminate\Contracts\Http\Kernel;
|
use Illuminate\Contracts\Http\Kernel;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue