1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2025-12-12 19:14:04 +00:00
tenancy/tests/RLS/TraitManagerTest.php
lukinovec 2057e1e5ae
[4.x] Make forcing RLS configurable (#1293)
* Add `$forceRls` static property to tenants:rls

* Set `$forceRls` in tests where scoping is tested, add non-superuser, non-bypassrls table owner test

* Move DROP TABLE statement

* Remove try/catch

* Put DROP OWNED BY into try/catch

* Static property cleanup in afterEach

* Make with() matrix syntax more clear by using with() multiple times

* Fix typo, improve comment

* Move and update force RLS comment

* Add test for `$forceRls = false`, refactor BYPASSRLS test

* Update link in test comment

* Add a dataset for `$forceRls` in the table owner test, fix BYPASSRLS test

* Correct PR link comment

* minor fixes

* Add test that makes the bypassrls/forceRls behavior clear

* Delete redundant test

* cleanup

* Update tests/RLS/TableManagerTest.php

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Samuel Štancl <samuel@archte.ch>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-06-05 05:06:05 +02:00

332 lines
12 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () {
CreateUserWithRLSPolicies::$forceRls = true;
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();
});
});
afterEach(function () {
CreateUserWithRLSPolicies::$forceRls = true;
});
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, bool $forceRls) {
CreateUserWithRLSPolicies::$forceRls = $forceRls;
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])->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');
}
}