mirror of
https://github.com/archtechx/tenancy.git
synced 2025-12-12 09:54: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
|
|
@ -13,18 +13,23 @@ use Stancl\Tenancy\Events\DatabaseCreated;
|
|||
use Stancl\Tenancy\Database\DatabaseManager;
|
||||
use Stancl\Tenancy\Events\TenancyInitialized;
|
||||
use Stancl\Tenancy\Listeners\BootstrapTenancy;
|
||||
use Stancl\Tenancy\Contracts\ManagesDatabaseUsers;
|
||||
use Stancl\Tenancy\Database\Contracts\ManagesDatabaseUsers;
|
||||
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
|
||||
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\Exceptions\TenantDatabaseUserAlreadyExistsException;
|
||||
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledMySQLDatabaseManager;
|
||||
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQLSchemaManager;
|
||||
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQLDatabaseManager;
|
||||
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledMicrosoftSQLServerDatabaseManager;
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'tenancy.database.managers.mysql' => PermissionControlledMySQLDatabaseManager::class,
|
||||
'tenancy.database.managers.sqlsrv' => PermissionControlledMicrosoftSQLServerDatabaseManager::class,
|
||||
'tenancy.database.managers.pgsql' => PermissionControlledPostgreSQLDatabaseManager::class,
|
||||
'tenancy.database.suffix' => '',
|
||||
'tenancy.database.template_tenant_connection' => 'mysql',
|
||||
]);
|
||||
|
|
@ -36,12 +41,20 @@ beforeEach(function () {
|
|||
'SHOW VIEW', 'TRIGGER', 'UPDATE',
|
||||
];
|
||||
|
||||
PermissionControlledMicrosoftSQLServerDatabaseManager::$grants = [
|
||||
'SELECT', 'INSERT', 'UPDATE', 'DELETE', 'EXECUTE',
|
||||
];
|
||||
|
||||
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
|
||||
return $event->tenant;
|
||||
})->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([
|
||||
'database.default' => $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();
|
||||
}
|
||||
})->with([
|
||||
'mysql',
|
||||
'sqlsrv',
|
||||
['mysql'],
|
||||
['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([
|
||||
'database.default' => $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();
|
||||
Event::assertNotDispatched(DatabaseCreated::class);
|
||||
})->with([
|
||||
'mysql',
|
||||
'sqlsrv',
|
||||
['mysql'],
|
||||
['sqlsrv'],
|
||||
['pgsql', PermissionControlledPostgreSQLDatabaseManager::class],
|
||||
['pgsql', PermissionControlledPostgreSQLSchemaManager::class],
|
||||
]);
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
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 () {
|
||||
config([
|
||||
'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([
|
||||
'tenancy.database.managers.mysql' => MySQLDatabaseManager::class,
|
||||
'tenancy.database.template_tenant_connection' => 'mysql',
|
||||
'database.default' => $driver,
|
||||
'tenancy.database.managers.' . $driver => $manager,
|
||||
'tenancy.database.template_tenant_connection' => $driver,
|
||||
'tenancy.bootstrappers' => [
|
||||
DatabaseTenancyBootstrapper::class,
|
||||
],
|
||||
|
|
@ -156,44 +205,20 @@ test('having existing databases without users and switching to permission contro
|
|||
'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()->end();
|
||||
|
||||
config(['tenancy.database.managers.mysql' => PermissionControlledMySQLDatabaseManager::class]);
|
||||
config(['tenancy.database.managers.' . $driver => $permissionControlledManager]);
|
||||
|
||||
tenancy()->initialize($tenant); // check if everything works
|
||||
|
||||
expect($tenant->database()->manager() instanceof PermissionControlledMySQLDatabaseManager)->toBeTrue();
|
||||
expect(config('database.connections.tenant.username'))->toBe('root');
|
||||
});
|
||||
|
||||
test('having existing databases without users and switching to permission controlled sqlsrv manager doesnt break existing dbs', function () {
|
||||
config([
|
||||
'database.default' => 'sqlsrv',
|
||||
'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
|
||||
});
|
||||
expect($tenant->database()->manager() instanceof $permissionControlledManager)->toBeTrue();
|
||||
expect(config('database.connections.tenant.username'))->toBe($defaultUser);
|
||||
})->with([
|
||||
['mysql', MySQLDatabaseManager::class, PermissionControlledMySQLDatabaseManager::class, 'root'],
|
||||
['pgsql', PostgreSQLDatabaseManager::class, PermissionControlledPostgreSQLDatabaseManager::class, 'root'],
|
||||
['pgsql', PostgreSQLSchemaManager::class, PermissionControlledPostgreSQLSchemaManager::class, 'root'],
|
||||
['sqlsrv', MicrosoftSQLDatabaseManager::class, PermissionControlledMicrosoftSQLServerDatabaseManager::class, 'sa'],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ test('manual tenancy initialization works', function () {
|
|||
pest()->assertArrayNotHasKey('tenant', config('database.connections'));
|
||||
|
||||
tenancy()->initialize($tenant);
|
||||
|
||||
|
||||
// Trigger creation of the tenant connection
|
||||
createUsersTable();
|
||||
|
||||
|
|
|
|||
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\Support\Facades\Validator;
|
||||
use Stancl\Tenancy\Database\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Stancl\Tenancy\Database\Concerns\BelongsToTenant;
|
||||
use Stancl\Tenancy\Database\Concerns\BelongsToPrimaryModel;
|
||||
use Stancl\Tenancy\Database\Concerns\HasScopedValidationRules;
|
||||
|
|
@ -40,12 +41,12 @@ test('primary models are scoped to the current tenant', function () {
|
|||
'id' => 'acme',
|
||||
]));
|
||||
|
||||
$post = Post::create(['text' => 'Foo']);
|
||||
$post = SingleDatabasePost::create(['text' => 'Foo']);
|
||||
|
||||
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');
|
||||
|
|
@ -56,12 +57,12 @@ test('primary models are scoped to the current tenant', function () {
|
|||
'id' => 'foobar',
|
||||
]));
|
||||
|
||||
$post = Post::create(['text' => 'Bar']);
|
||||
$post = SingleDatabasePost::create(['text' => 'Bar']);
|
||||
|
||||
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');
|
||||
|
|
@ -71,17 +72,17 @@ test('primary models are scoped to the current tenant', function () {
|
|||
|
||||
tenancy()->initialize($acme);
|
||||
|
||||
$post = Post::first();
|
||||
$post = SingleDatabasePost::first();
|
||||
expect($post->tenant_id)->toBe('acme');
|
||||
expect($post->tenant->id)->toBe('acme');
|
||||
|
||||
// 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
|
||||
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 () {
|
||||
|
|
@ -90,10 +91,10 @@ test('secondary models ARE scoped to the current tenant when accessed directly a
|
|||
]);
|
||||
|
||||
$acme->run(function () {
|
||||
$post = Post::create(['text' => 'Foo']);
|
||||
$post->scoped_comments()->create(['text' => 'Comment Text']);
|
||||
$post = SingleDatabasePost::create(['text' => 'Foo']);
|
||||
$post->comments()->create(['text' => 'Comment Text']);
|
||||
|
||||
expect(Post::count())->toBe(1);
|
||||
expect(SingleDatabasePost::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 () {
|
||||
expect(Post::count())->toBe(0);
|
||||
expect(SingleDatabasePost::count())->toBe(0);
|
||||
expect(ScopedComment::count())->toBe(0);
|
||||
|
||||
$post = Post::create(['text' => 'Bar']);
|
||||
$post->scoped_comments()->create(['text' => 'Comment Text 2']);
|
||||
$post = SingleDatabasePost::create(['text' => 'Bar']);
|
||||
$post->comments()->create(['text' => 'Comment Text 2']);
|
||||
|
||||
expect(Post::count())->toBe(1);
|
||||
expect(SingleDatabasePost::count())->toBe(1);
|
||||
expect(ScopedComment::count())->toBe(1);
|
||||
// whereas...
|
||||
expect(Comment::count())->toBe(2);
|
||||
});
|
||||
|
||||
// Global context
|
||||
|
|
@ -123,7 +126,7 @@ test('secondary models are scoped correctly', function () {
|
|||
'id' => 'acme',
|
||||
]));
|
||||
|
||||
$post = Post::create(['text' => 'Foo']);
|
||||
$post = SingleDatabasePost::create(['text' => 'Foo']);
|
||||
$post->comments()->create(['text' => 'Comment text']);
|
||||
|
||||
// ================
|
||||
|
|
@ -132,24 +135,24 @@ test('secondary models are scoped correctly', function () {
|
|||
'id' => 'foobar',
|
||||
]));
|
||||
|
||||
$post = Post::create(['text' => 'Bar']);
|
||||
$post = SingleDatabasePost::create(['text' => 'Bar']);
|
||||
$post->comments()->create(['text' => 'Comment text 2']);
|
||||
|
||||
// ================
|
||||
// acme context again
|
||||
tenancy()->initialize($acme);
|
||||
expect(Post::count())->toBe(1);
|
||||
expect(Post::first()->comments->count())->toBe(1);
|
||||
expect(SingleDatabasePost::count())->toBe(1);
|
||||
expect(SingleDatabasePost::first()->comments->count())->toBe(1);
|
||||
|
||||
// Secondary models are not scoped to the current tenant when accessed directly
|
||||
expect(tenant('id'))->toBe('acme');
|
||||
|
||||
expect(Comment::count())->toBe(2);
|
||||
expect(BaseComment::count())->toBe(2);
|
||||
|
||||
// secondary models are not scoped in the central context
|
||||
tenancy()->end();
|
||||
|
||||
expect(Comment::count())->toBe(2);
|
||||
expect(BaseComment::count())->toBe(2);
|
||||
});
|
||||
|
||||
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',
|
||||
]));
|
||||
|
||||
$post = Post::create(['text' => 'Foo']);
|
||||
$post = SingleDatabasePost::create(['text' => 'Foo']);
|
||||
|
||||
expect($post->tenant_id)->toBe('acme');
|
||||
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 () {
|
||||
pest()->expectException(QueryException::class);
|
||||
|
||||
Post::create(['text' => 'Foo']);
|
||||
SingleDatabasePost::create(['text' => 'Foo']);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
$post = Post::create(['text' => 'Foo']);
|
||||
$post = SingleDatabasePost::create(['text' => 'Foo']);
|
||||
|
||||
expect($post->team_id)->toBe('acme');
|
||||
|
||||
|
|
@ -224,11 +227,11 @@ test('tenant id column name can be customized', function () {
|
|||
'id' => 'foobar',
|
||||
]));
|
||||
|
||||
$post = Post::create(['text' => 'Bar']);
|
||||
$post = SingleDatabasePost::create(['text' => 'Bar']);
|
||||
|
||||
expect($post->team_id)->toBe('foobar');
|
||||
|
||||
$post = Post::first();
|
||||
$post = SingleDatabasePost::first();
|
||||
|
||||
expect($post->team_id)->toBe('foobar');
|
||||
|
||||
|
|
@ -237,11 +240,11 @@ test('tenant id column name can be customized', function () {
|
|||
|
||||
tenancy()->initialize($acme);
|
||||
|
||||
$post = Post::first();
|
||||
$post = SingleDatabasePost::first();
|
||||
expect($post->team_id)->toBe('acme');
|
||||
|
||||
// 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 () {
|
||||
|
|
@ -254,7 +257,7 @@ test('the model returned by the tenant helper has unique and exists validation r
|
|||
'id' => 'acme',
|
||||
]));
|
||||
|
||||
Post::create(['text' => 'Foo', 'slug' => 'foo']);
|
||||
SingleDatabasePost::create(['text' => 'Foo', 'slug' => 'foo']);
|
||||
$data = ['text' => 'Foo 2', 'slug' => 'foo'];
|
||||
|
||||
$uniqueFails = Validator::make($data, [
|
||||
|
|
@ -285,38 +288,40 @@ class SingleDatabaseTenant extends Tenant
|
|||
use HasScopedValidationRules;
|
||||
}
|
||||
|
||||
class Post extends Model
|
||||
class SingleDatabasePost extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
public $table = 'posts';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
public function comments()
|
||||
{
|
||||
return $this->hasMany(Comment::class);
|
||||
}
|
||||
|
||||
public function scoped_comments()
|
||||
{
|
||||
return $this->hasMany(Comment::class);
|
||||
return $this->hasMany(BaseComment::class, 'post_id');
|
||||
}
|
||||
}
|
||||
|
||||
class Comment extends Model
|
||||
class BaseComment extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
|
||||
protected $table = 'comments';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
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;
|
||||
|
||||
|
|
@ -326,6 +331,11 @@ class ScopedComment extends Comment
|
|||
{
|
||||
return 'post';
|
||||
}
|
||||
|
||||
public function post(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SingleDatabasePost::class);
|
||||
}
|
||||
}
|
||||
|
||||
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\MicrosoftSQLDatabaseManager;
|
||||
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledMySQLDatabaseManager;
|
||||
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQLSchemaManager;
|
||||
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQLDatabaseManager;
|
||||
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledMicrosoftSQLServerDatabaseManager;
|
||||
|
||||
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
|
||||
$username = 'dbuser' . Str::random(4);
|
||||
$password = Str::random('8');
|
||||
$password = Str::random(8);
|
||||
$mysql2DB = DB::connection('mysql2');
|
||||
$mysql2DB->statement("CREATE USER `{$username}`@`%` IDENTIFIED BY '{$password}';");
|
||||
$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
|
||||
$username = 'dbuser' . Str::random(4);
|
||||
$password = Str::random('8');
|
||||
$password = Str::random(8);
|
||||
$mysqlDB = DB::connection('mysql');
|
||||
$mysqlDB->statement("CREATE USER `{$username}`@`%` IDENTIFIED BY '{$password}';");
|
||||
$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);
|
||||
|
||||
$tenant = Tenant::create([
|
||||
'tenancy_db_name' => $name,
|
||||
]);
|
||||
|
|
@ -479,6 +482,8 @@ dataset('database_managers', [
|
|||
['sqlite', SQLiteDatabaseManager::class],
|
||||
['pgsql', PostgreSQLDatabaseManager::class],
|
||||
['pgsql', PostgreSQLSchemaManager::class],
|
||||
['pgsql', PermissionControlledPostgreSQLDatabaseManager::class],
|
||||
['pgsql', PermissionControlledPostgreSQLSchemaManager::class],
|
||||
['sqlsrv', MicrosoftSQLDatabaseManager::class],
|
||||
['sqlsrv', PermissionControlledMicrosoftSQLServerDatabaseManager::class]
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -4,23 +4,24 @@ declare(strict_types=1);
|
|||
|
||||
namespace Stancl\Tenancy\Tests;
|
||||
|
||||
use Aws\DynamoDb\DynamoDbClient;
|
||||
use PDO;
|
||||
use Dotenv\Dotenv;
|
||||
use Aws\DynamoDb\DynamoDbClient;
|
||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Stancl\Tenancy\Bootstrappers\BroadcastChannelPrefixBootstrapper;
|
||||
use Stancl\Tenancy\Facades\GlobalCache;
|
||||
use Stancl\Tenancy\TenancyServiceProvider;
|
||||
use Stancl\Tenancy\Facades\Tenancy as TenancyFacade;
|
||||
use Stancl\Tenancy\Bootstrappers\RootUrlBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\MailConfigBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\PostgresRLSBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\UrlGeneratorBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\BroadcastingConfigBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper;
|
||||
use Stancl\Tenancy\Bootstrappers\BroadcastChannelPrefixBootstrapper;
|
||||
|
||||
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'), '');
|
||||
|
||||
pest()->artisan('migrate:fresh', [
|
||||
'--force' => true,
|
||||
'--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(BroadcastingConfigBootstrapper::class);
|
||||
$app->singleton(BroadcastChannelPrefixBootstrapper::class);
|
||||
$app->singleton(PostgresRLSBootstrapper::class);
|
||||
$app->singleton(MailConfigBootstrapper::class);
|
||||
$app->singleton(RootUrlBootstrapper::class);
|
||||
$app->singleton(UrlGeneratorBootstrapper::class);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ declare(strict_types=1);
|
|||
|
||||
use Stancl\Tenancy\Tenancy;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Route;
|
||||
use Stancl\Tenancy\Enums\RouteMode;
|
||||
use Stancl\Tenancy\Tests\Etc\Tenant;
|
||||
use Illuminate\Contracts\Http\Kernel;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue