1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2025-12-12 19:04:02 +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:
Samuel Štancl 2024-04-24 22:32:49 +02:00 committed by GitHub
parent 34297d3e1a
commit 7317d2638a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 2511 additions and 112 deletions

197
tests/RLS/PolicyTest.php Normal file
View 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,
]);