From 7317d2638ab6b200ee9e109bdf6a8960cdbdd669 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Wed, 24 Apr 2024 22:32:49 +0200 Subject: [PATCH] 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 Co-authored-by: PHP CS Fixer --- .github/workflows/ci.yml | 27 +- INTERNAL.md | 6 + assets/config.php | 31 + ...2019_09_15_000020_create_domains_table.php | 8 +- composer.json | 4 +- docker-compose.yml | 2 +- phpstan.neon | 8 + src/Bootstrappers/PostgresRLSBootstrapper.php | 63 ++ .../QueueTenancyBootstrapper.php | 2 +- src/Commands/CreateUserWithRLSPolicies.php | 211 ++++++ src/Concerns/ManagesRLSPolicies.php | 34 + .../Concerns/BelongsToPrimaryModel.php | 7 +- src/Database/Concerns/BelongsToTenant.php | 18 +- .../Concerns/CreatesDatabaseUsers.php | 5 +- src/Database/Concerns/FillsCurrentTenant.php | 22 + .../Concerns/ManagesPostgresUsers.php | 83 +++ src/Database/Concerns/RLSModel.php | 21 + .../RecursiveRelationshipException.php | 18 + .../TableNotRelatedToTenantException.php | 18 + ...ionControlledPostgreSQLDatabaseManager.php | 47 ++ ...ssionControlledPostgreSQLSchemaManager.php | 56 ++ .../PostgreSQLDatabaseManager.php | 2 +- src/RLS/PolicyManagers/RLSPolicyManager.php | 15 + src/RLS/PolicyManagers/TableRLSManager.php | 260 +++++++ src/RLS/PolicyManagers/TraitRLSManager.php | 134 ++++ src/Tenancy.php | 3 +- src/TenancyServiceProvider.php | 1 + tests/DatabaseUsersTest.php | 113 +-- tests/ManualModeTest.php | 2 +- tests/RLS/Etc/Article.php | 15 + tests/RLS/Etc/Comment.php | 27 + tests/RLS/Etc/Post.php | 25 + tests/RLS/PolicyTest.php | 197 +++++ tests/RLS/TableManagerTest.php | 705 ++++++++++++++++++ tests/RLS/TraitManagerTest.php | 328 ++++++++ tests/SingleDatabaseTenancyTest.php | 86 ++- tests/TenantDatabaseManagerTest.php | 9 +- tests/TestCase.php | 9 +- tests/UniversalRouteTest.php | 1 - 39 files changed, 2511 insertions(+), 112 deletions(-) create mode 100644 src/Bootstrappers/PostgresRLSBootstrapper.php create mode 100644 src/Commands/CreateUserWithRLSPolicies.php create mode 100644 src/Concerns/ManagesRLSPolicies.php create mode 100644 src/Database/Concerns/FillsCurrentTenant.php create mode 100644 src/Database/Concerns/ManagesPostgresUsers.php create mode 100644 src/Database/Concerns/RLSModel.php create mode 100644 src/Database/Exceptions/RecursiveRelationshipException.php create mode 100644 src/Database/Exceptions/TableNotRelatedToTenantException.php create mode 100644 src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLDatabaseManager.php create mode 100644 src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php create mode 100644 src/RLS/PolicyManagers/RLSPolicyManager.php create mode 100644 src/RLS/PolicyManagers/TableRLSManager.php create mode 100644 src/RLS/PolicyManagers/TraitRLSManager.php create mode 100644 tests/RLS/Etc/Article.php create mode 100644 tests/RLS/Etc/Comment.php create mode 100644 tests/RLS/Etc/Post.php create mode 100644 tests/RLS/PolicyTest.php create mode 100644 tests/RLS/TableManagerTest.php create mode 100644 tests/RLS/TraitManagerTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e220bd99..67f022fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,13 +22,35 @@ jobs: steps: - name: Checkout uses: actions/checkout@v2 - - name: Install Composer dependencies run: | composer require "laravel/framework:${{ matrix.laravel }}" --no-interaction --no-update composer update --prefer-dist --no-interaction - name: Run tests - run: COLUMNS=200 ./vendor/bin/pest --compact --colors=always + if: ${{ ! env.ACT }} + run: COLUMNS=200 ./vendor/bin/pest -v --compact --colors=always + env: + DB_PASSWORD: password + DB_USERNAME: root + DB_DATABASE: main + TENANCY_TEST_MYSQL_HOST: mysql + TENANCY_TEST_PGSQL_HOST: postgres + TENANCY_TEST_REDIS_HOST: redis + TENANCY_TEST_SQLSRV_HOST: mssql + - name: Run tests (via act, no filter) + if: ${{ env.ACT && ! github.event.inputs.FILTER }} + run: COLUMNS=200 ./vendor/bin/pest -v --compact --colors=always + env: + DB_PASSWORD: password + DB_USERNAME: root + DB_DATABASE: main + TENANCY_TEST_MYSQL_HOST: mysql + TENANCY_TEST_PGSQL_HOST: postgres + TENANCY_TEST_REDIS_HOST: redis + TENANCY_TEST_SQLSRV_HOST: mssql + - name: Run tests (via act, FILTERED) + if: ${{ env.ACT && github.event.inputs.FILTER }} + run: COLUMNS=200 ./vendor/bin/pest -v --filter ${{ github.event.inputs.FILTER }} --compact --colors=always env: DB_PASSWORD: password DB_USERNAME: root @@ -39,6 +61,7 @@ jobs: TENANCY_TEST_SQLSRV_HOST: mssql - name: Upload coverage to Codecov + if: ${{ !env.ACT }} uses: codecov/codecov-action@v2 with: token: 24382d15-84e7-4a55-bea4-c4df96a24a9b # todo it's fine if this is here in plaintext, but move this to GH secrets eventually diff --git a/INTERNAL.md b/INTERNAL.md index bb65c6fb..b49fee2f 100644 --- a/INTERNAL.md +++ b/INTERNAL.md @@ -10,3 +10,9 @@ 1. Tag a new image: `docker tag tenancy-test archtechx/tenancy:latest` 1. Push the image: `docker push archtechx/tenancy:latest` 1. Optional: Rebuild the image again locally for arm64: `composer docker-rebuild` + +## Debugging GitHub Actions + +The `ci.yml` workflow includes support for [act](https://github.com/nektos/act). + +To run all tests using act, run `composer act`. To run only certain tests using act, use `composer act-input "FILTER='some test name'"` or `composer act -- --input "FILTER='some test name'"`. diff --git a/assets/config.php b/assets/config.php index c146b61a..0291905b 100644 --- a/assets/config.php +++ b/assets/config.php @@ -162,6 +162,8 @@ return [ // Integration bootstrappers // Bootstrappers\Integrations\FortifyRouteBootstrapper::class, // Bootstrappers\Integrations\ScoutPrefixBootstrapper::class, + + // Bootstrappers\PostgresRLSBootstrapper::class, ], /** @@ -215,6 +217,35 @@ return [ 'drop_tenant_databases_on_migrate_fresh' => false, ], + /** + * Requires PostgreSQL with single-database tenancy. + */ + 'rls' => [ + /** + * The RLS manager responsible for generating queries for creating policies. + * + * @see Stancl\Tenancy\RLS\PolicyManagers\TableRLSManager + * @see Stancl\Tenancy\RLS\PolicyManagers\TraitRLSManager + */ + 'manager' => Stancl\Tenancy\RLS\PolicyManagers\TableRLSManager::class, + + /** + * Credentials for the tenant database user (one user for *all* tenants, not for each tenant). + */ + 'user' => [ + 'username' => env('TENANCY_RLS_USERNAME'), + 'password' => env('TENANCY_RLS_PASSWORD'), + ], + + /** + * Postgres session variable used to store the current tenant key. + * + * The variable name has to include a namespace – for example, 'my.'. + * The namespace is required because the global one is reserved for the server configuration + */ + 'session_variable_name' => 'my.current_tenant', + ], + /** * Cache tenancy config. Used by the CacheTenancyBootstrapper, the CacheTagsBootstrapper, and the custom CacheManager. * diff --git a/assets/migrations/2019_09_15_000020_create_domains_table.php b/assets/migrations/2019_09_15_000020_create_domains_table.php index ac238830..99159172 100644 --- a/assets/migrations/2019_09_15_000020_create_domains_table.php +++ b/assets/migrations/2019_09_15_000020_create_domains_table.php @@ -2,10 +2,10 @@ declare(strict_types=1); -use Illuminate\Database\Migrations\Migration; -use Illuminate\Database\Schema\Blueprint; -use Illuminate\Support\Facades\Schema; use Stancl\Tenancy\Tenancy; +use Illuminate\Support\Facades\Schema; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Database\Migrations\Migration; return new class extends Migration { @@ -19,7 +19,7 @@ return new class extends Migration Schema::create('domains', function (Blueprint $table) { $table->increments('id'); $table->string('domain', 255)->unique(); - $table->string(Tenancy::tenantKeyColumn()); + $table->string(Tenancy::tenantKeyColumn())->comment('no-rls'); $table->timestamps(); $table->foreign(Tenancy::tenantKeyColumn())->references('id')->on('tenants')->onUpdate('cascade'); diff --git a/composer.json b/composer.json index ff5e531a..3a26584f 100644 --- a/composer.json +++ b/composer.json @@ -73,7 +73,9 @@ "phpstan-pro": "vendor/bin/phpstan --pro", "cs": "php-cs-fixer fix --config=.php-cs-fixer.php", "test": "./test --no-coverage", - "test-full": "./test" + "test-full": "./test", + "act": "act -j tests --matrix 'laravel:^11.0'", + "act-input": "act -j tests --matrix 'laravel:^11.0' --input" }, "minimum-stability": "dev", "prefer-stable": true, diff --git a/docker-compose.yml b/docker-compose.yml index 9c6daef2..d0036e32 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -62,7 +62,7 @@ services: tmpfs: - /var/lib/mysql postgres: - image: postgres:11 + image: postgres:16 environment: POSTGRES_PASSWORD: password POSTGRES_USER: root # superuser name diff --git a/phpstan.neon b/phpstan.neon index 7442b209..cb6b2e3e 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -48,6 +48,14 @@ parameters: paths: - src/Controllers/TenantAssetController.php - '#expects int<1, max>, int given#' + - + message: '#Call to an undefined method Illuminate\\Database\\Eloquent\\Model\:\:tenant\(\)#' + paths: + - src/RLS/PolicyManagers/TraitRLSManager.php + - + message: '#Call to an undefined method Illuminate\\Database\\Eloquent\\Model\:\:getRelationshipToPrimaryModel\(\)#' + paths: + - src/RLS/PolicyManagers/TraitRLSManager.php checkMissingIterableValueType: false checkGenericClassInNonGenericObjectType: false # later we may want to enable this diff --git a/src/Bootstrappers/PostgresRLSBootstrapper.php b/src/Bootstrappers/PostgresRLSBootstrapper.php new file mode 100644 index 00000000..1191788d --- /dev/null +++ b/src/Bootstrappers/PostgresRLSBootstrapper.php @@ -0,0 +1,63 @@ +connectToTenant(); + + $tenantSessionKey = $this->config->get('tenancy.rls.session_variable_name'); + + $this->database->statement("SET {$tenantSessionKey} = '{$tenant->getTenantKey()}'"); + } + + public function revert(): void + { + $this->database->statement("RESET {$this->config->get('tenancy.rls.session_variable_name')}"); + + $this->tenantConnectionManager->reconnectToCentral(); + } + + protected function connectToTenant(): void + { + $centralConnection = $this->config->get('tenancy.database.central_connection'); + + $this->tenantConnectionManager->purgeTenantConnection(); + + $tenantConnection = array_merge($this->config->get('database.connections.' . $centralConnection), [ + 'username' => $this->config->get('tenancy.rls.user.username'), + 'password' => $this->config->get('tenancy.rls.user.password'), + ]); + + $this->config['database.connections.tenant'] = $tenantConnection; + + $this->tenantConnectionManager->setDefaultConnection('tenant'); + } +} diff --git a/src/Bootstrappers/QueueTenancyBootstrapper.php b/src/Bootstrappers/QueueTenancyBootstrapper.php index 3f46a112..577e90d1 100644 --- a/src/Bootstrappers/QueueTenancyBootstrapper.php +++ b/src/Bootstrappers/QueueTenancyBootstrapper.php @@ -105,7 +105,7 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper if (tenancy()->initialized) { // Tenancy is already initialized if (tenant()->getTenantKey() === $tenantId) { - // It's initialized for the same tenant (e.g. dispatchNow was used, or the previous job also ran for this tenant) + // It's initialized for the same tenant (e.g. dispatchSync was used, or the previous job also ran for this tenant) return; } } diff --git a/src/Commands/CreateUserWithRLSPolicies.php b/src/Commands/CreateUserWithRLSPolicies.php new file mode 100644 index 00000000..45b25278 --- /dev/null +++ b/src/Commands/CreateUserWithRLSPolicies.php @@ -0,0 +1,211 @@ +components->error('The RLS user credentials are not set in the "tenancy.rls.user" config.'); + + return Command::FAILURE; + } + + $this->components->info( + $manager->createUser($this->makeDatabaseConfig($manager, $username, $password)) + ? "RLS user '{$username}' has been created." + : "RLS user '{$username}' already exists." + ); + + $this->createTablePolicies(); + + return Command::SUCCESS; + } + + protected function enableRLS(string $table): void + { + // Enable RLS scoping on the table (without this, queries won't be scoped using RLS) + DB::statement("ALTER TABLE {$table} ENABLE ROW LEVEL SECURITY"); + + /** + * Force RLS scoping on the table, so that the table owner users + * don't bypass the scoping – table owners bypass RLS by default. + * + * E.g. when using a custom implementation where you create tables as the RLS user, + * the queries won't be scoped for the RLS user unless we force the RLS scoping using this query. + */ + DB::statement("ALTER TABLE {$table} FORCE ROW LEVEL SECURITY"); + } + + /** + * Create a DatabaseConfig instance for the RLS user, + * so that the user can get created using $manager->createUser($databaseConfig). + */ + protected function makeDatabaseConfig( + PermissionControlledPostgreSQLSchemaManager $manager, + string $username, + string $password, + ): DatabaseConfig { + /** @var TenantWithDatabase $tenantModel */ + $tenantModel = tenancy()->model(); + + // Use a temporary DatabaseConfig instance to set the host connection + $temporaryDbConfig = $tenantModel->database(); + + $temporaryDbConfig->purgeHostConnection(); + + $tenantHostConnectionName = $temporaryDbConfig->getTenantHostConnectionName(); + config(["database.connections.{$tenantHostConnectionName}" => $temporaryDbConfig->hostConnection()]); + + // Use the tenant host connection in the manager + $manager->setConnection($tenantModel->database()->getTenantHostConnectionName()); + + // Set the database name (= central schema name/search_path in this case), username, and password + $tenantModel->setInternal('db_name', $manager->database()->getConfig('search_path')); + $tenantModel->setInternal('db_username', $username); + $tenantModel->setInternal('db_password', $password); + + return $tenantModel->database(); + } + + protected function createTablePolicies(): void + { + /** @var RLSPolicyManager $rlsPolicyManager */ + $rlsPolicyManager = app(config('tenancy.rls.manager')); + $rlsQueries = $rlsPolicyManager->generateQueries(); + + $zombiePolicies = $this->dropZombiePolicies(array_keys($rlsQueries)); + + if ($zombiePolicies > 0) { + $this->components->warn("Dropped {$zombiePolicies} zombie RLS policies."); + } + + $createdPolicies = []; + + foreach ($rlsQueries as $table => $query) { + [$hash, $policyQuery] = $this->hashPolicy($query); + $expectedName = $table . '_rls_policy_' . $hash; + + $tableRLSPolicy = $this->findTableRLSPolicy($table); + $olderPolicyExists = $tableRLSPolicy && $tableRLSPolicy->policyname !== $expectedName; + + // Drop the policy if an outdated version exists + // or if it exists (even in the current form) and the --force option is used + $dropPolicy = $olderPolicyExists || ($tableRLSPolicy && $this->option('force')); + + if ($tableRLSPolicy && $dropPolicy) { + DB::statement("DROP POLICY {$tableRLSPolicy->policyname} ON {$table}"); + + $this->components->info("RLS policy for table '{$table}' dropped."); + } + + // Create RLS policy if the table doesn't have it or if the --force option is used + $createPolicy = $dropPolicy || ! $tableRLSPolicy || $this->option('force'); + + if ($createPolicy) { + DB::statement($policyQuery); + + $this->enableRLS($table); + + $createdPolicies[] = $table . " ($hash)"; + } else { + $this->components->info("Table '{$table}' already has an up to date RLS policy."); + } + } + + if (! empty($createdPolicies)) { + $managerName = str($rlsPolicyManager::class)->afterLast('\\')->toString(); + + $this->components->info("RLS policies created for tables (using {$managerName}):"); + + $this->components->bulletList($createdPolicies); + + $this->components->info('RLS policies updated successfully.'); + } else { + $this->components->info('All RLS policies are up to date.'); + } + } + + /** @return \stdClass|null */ + protected function findTableRLSPolicy(string $table): object|null + { + return DB::selectOne(<<tablename, $tablesThatShouldHavePolicies, true)) { + DB::statement("DROP POLICY {$table->policyname} ON {$table->tablename}"); + + $this->components->warn("RLS policy for table '{$table->tablename}' dropped (zombie)."); + $zombies++; + } + } + + return $zombies; + } +} diff --git a/src/Concerns/ManagesRLSPolicies.php b/src/Concerns/ManagesRLSPolicies.php new file mode 100644 index 00000000..6b804fb7 --- /dev/null +++ b/src/Concerns/ManagesRLSPolicies.php @@ -0,0 +1,34 @@ + $policy->policyname, + DB::select("SELECT policyname FROM pg_policies WHERE tablename = '{$table}' AND policyname LIKE '%_rls_policy%'") + ); + } + + public static function dropRLSPolicies(string $table): int + { + $policies = static::getRLSPolicies($table); + + foreach ($policies as $policy) { + DB::statement('DROP POLICY ? ON ?', [$policy, $table]); + } + + return count($policies); + } +} diff --git a/src/Database/Concerns/BelongsToPrimaryModel.php b/src/Database/Concerns/BelongsToPrimaryModel.php index a50f9d9e..2c8c435f 100644 --- a/src/Database/Concerns/BelongsToPrimaryModel.php +++ b/src/Database/Concerns/BelongsToPrimaryModel.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Stancl\Tenancy\Database\Concerns; use Stancl\Tenancy\Database\ParentModelScope; +use Stancl\Tenancy\RLS\PolicyManagers\TraitRLSManager; trait BelongsToPrimaryModel { @@ -12,6 +13,10 @@ trait BelongsToPrimaryModel public static function bootBelongsToPrimaryModel(): void { - static::addGlobalScope(new ParentModelScope); + $implicitRLS = config('tenancy.rls.manager') === TraitRLSManager::class && TraitRLSManager::$implicitRLS; + + if (! $implicitRLS && ! (new static) instanceof RLSModel) { + static::addGlobalScope(new ParentModelScope); + } } } diff --git a/src/Database/Concerns/BelongsToTenant.php b/src/Database/Concerns/BelongsToTenant.php index 3ca9703c..f26a7ff8 100644 --- a/src/Database/Concerns/BelongsToTenant.php +++ b/src/Database/Concerns/BelongsToTenant.php @@ -7,6 +7,7 @@ namespace Stancl\Tenancy\Database\Concerns; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Database\TenantScope; +use Stancl\Tenancy\RLS\PolicyManagers\TraitRLSManager; use Stancl\Tenancy\Tenancy; /** @@ -14,6 +15,8 @@ use Stancl\Tenancy\Tenancy; */ trait BelongsToTenant { + use FillsCurrentTenant; + public function tenant(): BelongsTo { return $this->belongsTo(config('tenancy.models.tenant'), Tenancy::tenantKeyColumn()); @@ -21,15 +24,12 @@ trait BelongsToTenant public static function bootBelongsToTenant(): void { - static::addGlobalScope(new TenantScope); + // If TraitRLSManager::$implicitRLS is true or this model implements RLSModel + // Postgres RLS is used for scoping, so we don't enable the scope used with single-database tenancy. + $implicitRLS = config('tenancy.rls.manager') === TraitRLSManager::class && TraitRLSManager::$implicitRLS; - static::creating(function ($model) { - if (! $model->getAttribute(Tenancy::tenantKeyColumn()) && ! $model->relationLoaded('tenant')) { - if (tenancy()->initialized) { - $model->setAttribute(Tenancy::tenantKeyColumn(), tenant()->getTenantKey()); - $model->setRelation('tenant', tenant()); - } - } - }); + if (! $implicitRLS && ! (new static) instanceof RLSModel) { + static::addGlobalScope(new TenantScope); + } } } diff --git a/src/Database/Concerns/CreatesDatabaseUsers.php b/src/Database/Concerns/CreatesDatabaseUsers.php index f329f071..8e102fd0 100644 --- a/src/Database/Concerns/CreatesDatabaseUsers.php +++ b/src/Database/Concerns/CreatesDatabaseUsers.php @@ -17,8 +17,9 @@ trait CreatesDatabaseUsers public function deleteDatabase(TenantWithDatabase $tenant): bool { - parent::deleteDatabase($tenant); + // Some DB engines require the user to be deleted before the database (e.g. Postgres) + $this->deleteUser($tenant->database()); - return $this->deleteUser($tenant->database()); + return parent::deleteDatabase($tenant); } } diff --git a/src/Database/Concerns/FillsCurrentTenant.php b/src/Database/Concerns/FillsCurrentTenant.php new file mode 100644 index 00000000..0a3648e7 --- /dev/null +++ b/src/Database/Concerns/FillsCurrentTenant.php @@ -0,0 +1,22 @@ +getAttribute(Tenancy::tenantKeyColumn()) && ! $model->relationLoaded('tenant')) { + if (tenancy()->initialized) { + $model->setAttribute(Tenancy::tenantKeyColumn(), tenant()->getTenantKey()); + $model->setRelation('tenant', tenant()); + } + } + }); + } +} diff --git a/src/Database/Concerns/ManagesPostgresUsers.php b/src/Database/Concerns/ManagesPostgresUsers.php new file mode 100644 index 00000000..f73bac1c --- /dev/null +++ b/src/Database/Concerns/ManagesPostgresUsers.php @@ -0,0 +1,83 @@ +getName(). + */ + abstract protected function grantPermissions(DatabaseConfig $databaseConfig): bool; + + public function createUser(DatabaseConfig $databaseConfig): bool + { + /** @var string $username */ + $username = $databaseConfig->getUsername(); + $password = $databaseConfig->getPassword(); + + $createUser = ! $this->userExists($username); + + if ($createUser) { + $this->database()->statement("CREATE USER \"{$username}\" LOGIN PASSWORD '{$password}'"); + } + + $this->grantPermissions($databaseConfig); + + return $createUser; + } + + public function deleteUser(DatabaseConfig $databaseConfig): bool + { + /** @var TenantDatabaseManager $this */ + + // Tenant DB username + $username = $databaseConfig->getUsername(); + + // Tenant host connection config + $connectionName = $this->database()->getConfig('name'); + $centralDatabase = $this->database()->getConfig('database'); + + // Set the DB/schema name to the tenant DB/schema name + config()->set( + "database.connections.{$connectionName}", + $this->makeConnectionConfig($this->database()->getConfig(), $databaseConfig->getName()), + ); + + // Connect to the tenant DB/schema + $this->database()->reconnect(); + + // Delete all database objects owned by the user (privileges, tables, views, etc.) + // Postgres users cannot be deleted unless we delete all objects owned by it first + $this->database()->statement("DROP OWNED BY \"{$username}\""); + + // Delete the user + $userDeleted = $this->database()->statement("DROP USER \"{$username}\""); + + config()->set( + "database.connections.{$connectionName}", + $this->makeConnectionConfig($this->database()->getConfig(), $centralDatabase), + ); + + // Reconnect to the central database + $this->database()->reconnect(); + + return $userDeleted; + } + + public function userExists(string $username): bool + { + return (bool) $this->database()->selectOne("SELECT usename FROM pg_user WHERE usename = '{$username}'"); + } +} diff --git a/src/Database/Concerns/RLSModel.php b/src/Database/Concerns/RLSModel.php new file mode 100644 index 00000000..35781b48 --- /dev/null +++ b/src/Database/Concerns/RLSModel.php @@ -0,0 +1,21 @@ +getName(); + $username = $databaseConfig->getUsername(); + $schema = $databaseConfig->connection()['search_path']; + + // Host config + $connectionName = $this->database()->getConfig('name'); + $centralDatabase = $this->database()->getConfig('database'); + + $this->database()->statement("GRANT CONNECT ON DATABASE \"{$database}\" TO \"{$username}\""); + + // Connect to tenant database + config(["database.connections.{$connectionName}.database" => $database]); + + $this->database()->reconnect(); + + // Grant permissions to create and use tables in the configured schema ("public" by default) to the user + $this->database()->statement("GRANT USAGE, CREATE ON SCHEMA {$schema} TO \"{$username}\""); + + // Grant permissions to use sequences in the current schema to the user + $this->database()->statement("GRANT USAGE ON ALL SEQUENCES IN SCHEMA {$schema} TO \"{$username}\""); + + // Reconnect to central database + config(["database.connections.{$connectionName}.database" => $centralDatabase]); + + $this->database()->reconnect(); + + return true; + } +} diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php new file mode 100644 index 00000000..96693cae --- /dev/null +++ b/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php @@ -0,0 +1,56 @@ +getUsername(); + $schema = $databaseConfig->getName(); + + // Central database name + $database = DB::connection(config('tenancy.database.central_connection'))->getDatabaseName(); + + $this->database()->statement("GRANT CONNECT ON DATABASE {$database} TO \"{$username}\""); + $this->database()->statement("GRANT USAGE, CREATE ON SCHEMA \"{$schema}\" TO \"{$username}\""); + $this->database()->statement("GRANT USAGE ON ALL SEQUENCES IN SCHEMA \"{$schema}\" TO \"{$username}\""); + + $tables = $this->database()->select("SELECT table_name FROM information_schema.tables WHERE table_schema = '{$schema}'"); + + // Grant permissions to any existing tables. This is used with RLS + // todo@samuel refactor this along with the todo in TenantDatabaseManager + // and move the grantPermissions() call inside the condition in `ManagesPostgresUsers::createUser()` + foreach ($tables as $table) { + $tableName = $table->table_name; + + /** @var string $primaryKey */ + $primaryKey = $this->database()->selectOne(<<column_name; + + // Grant all permissions for all existing tables + $this->database()->statement("GRANT ALL ON \"{$schema}\".\"{$tableName}\" TO \"{$username}\""); + + // Grant permission to reference the primary key for the table + // The previous query doesn't grant the references privilege, so it has to be granted here + $this->database()->statement("GRANT REFERENCES (\"{$primaryKey}\") ON \"{$schema}\".\"{$tableName}\" TO \"{$username}\""); + } + + return true; + } +} diff --git a/src/Database/TenantDatabaseManagers/PostgreSQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/PostgreSQLDatabaseManager.php index 6a3aee2e..88f1c78c 100644 --- a/src/Database/TenantDatabaseManagers/PostgreSQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/PostgreSQLDatabaseManager.php @@ -20,6 +20,6 @@ class PostgreSQLDatabaseManager extends TenantDatabaseManager public function databaseExists(string $name): bool { - return (bool) $this->database()->select("SELECT datname FROM pg_database WHERE datname = '$name'"); + return (bool) $this->database()->selectOne("SELECT datname FROM pg_database WHERE datname = '$name'"); } } diff --git a/src/RLS/PolicyManagers/RLSPolicyManager.php b/src/RLS/PolicyManagers/RLSPolicyManager.php new file mode 100644 index 00000000..a7cea276 --- /dev/null +++ b/src/RLS/PolicyManagers/RLSPolicyManager.php @@ -0,0 +1,15 @@ +shortestPaths() as $table => $path) { + $queries[$table] = $this->generateQuery($table, $path); + } + + return $queries; + } + + /** + * Reduce trees to shortest paths (structured like ['table_foo' => $shortestPathForFoo, 'table_bar' => $shortestPathForBar]). + * + * For example: + * + * 'posts' => [ + * [ + * 'foreignKey' => 'tenant_id', + * 'foreignTable' => 'tenants', + * 'foreignId' => 'id' + * ], + * ], + * 'comments' => [ + * [ + * 'foreignKey' => 'post_id', + * 'foreignTable' => 'posts', + * 'foreignId' => 'id' + * ], + * [ + * 'foreignKey' => 'tenant_id', + * 'foreignTable' => 'tenants', + * 'foreignId' => 'id' + * ], + * ], + */ + public function shortestPaths(array $trees = []): array + { + $reducedTrees = []; + + foreach ($trees ?: $this->generateTrees() as $table => $tree) { + $reducedTrees[$table] = $this->findShortestPath($this->filterNonNullablePaths($tree) ?: $tree); + } + + return $reducedTrees; + } + + /** + * Generate trees of paths that lead to the tenants table + * for the foreign keys of all tables – only the paths that lead to the tenants table are included. + * + * Also unset the 'comment' key from the retrieved path steps. + */ + public function generateTrees(): array + { + $trees = []; + $builder = $this->database->getSchemaBuilder(); + + // We loop through each table in the database + foreach ($builder->getTableListing() as $table) { + // For each table, we get a list of all foreign key columns + $foreignKeys = collect($builder->getForeignKeys($table))->map(function ($foreign) use ($table) { + return $this->formatForeignKey($foreign, $table); + }); + + // We loop through each foreign key column and find + // all possible paths that lead to the tenants table + foreach ($foreignKeys as $foreign) { + $paths = []; + + $this->generatePaths($table, $foreign, $paths); + + foreach ($paths as &$path) { + foreach ($path as &$step) { + unset($step['comment']); + } + } + + if (count($paths)) { + $trees[$table][$foreign['foreignKey']] = $paths; + } + } + } + + return $trees; + } + + protected function generatePaths(string $table, array $foreign, array &$paths, array $currentPath = []): void + { + if (in_array($foreign['foreignTable'], array_column($currentPath, 'foreignTable'))) { + throw new RecursiveRelationshipException; + } + + $currentPath[] = $foreign; + + if ($foreign['foreignTable'] === tenancy()->model()->getTable()) { + $comments = array_column($currentPath, 'comment'); + $pathCanUseRls = static::$scopeByDefault ? + ! in_array('no-rls', $comments) : + ! in_array('no-rls', $comments) && ! in_array(null, $comments); + + if ($pathCanUseRls) { + // If the foreign table is the tenants table, add the current path to $paths + $paths[] = $currentPath; + } + } else { + // If not, recursively generate paths for the foreign table + foreach ($this->database->getSchemaBuilder()->getForeignKeys($foreign['foreignTable']) as $nextConstraint) { + $this->generatePaths($table, $this->formatForeignKey($nextConstraint, $foreign['foreignTable']), $paths, $currentPath); + } + } + } + + /** Get tree's non-nullable paths. */ + protected function filterNonNullablePaths(array $tree): array + { + $nonNullablePaths = []; + + foreach ($tree as $foreignKey => $paths) { + foreach ($paths as $path) { + $pathIsNullable = false; + + foreach ($path as $step) { + if ($step['nullable']) { + $pathIsNullable = true; + break; + } + } + + if (! $pathIsNullable) { + $nonNullablePaths[$foreignKey][] = $path; + } + } + } + + return $nonNullablePaths; + } + + /** Find the shortest path in a tree and unset the 'nullable' key from the path steps. */ + protected function findShortestPath(array $tree): array + { + $shortestPath = []; + + foreach ($tree as $pathsForForeignKey) { + foreach ($pathsForForeignKey as $path) { + if (empty($shortestPath) || count($shortestPath) > count($path)) { + $shortestPath = $path; + + foreach ($shortestPath as &$step) { + unset($step['nullable']); + } + } + } + } + + return $shortestPath; + } + + /** + * Formats the foreign key array retrieved by Postgres to a more readable format. + * + * Also provides information about whether the foreign key is nullable, + * and the foreign key column comment. These additional details are removed + * from the foreign keys/path steps before returning the final shortest paths. + * + * The 'comment' key gets deleted while generating the full trees (in generateTrees()), + * and the 'nullable' key gets deleted while generating the shortest paths (in findShortestPath()). + * + * [ + * 'foreignKey' => 'tenant_id', + * 'foreignTable' => 'tenants', + * 'foreignId' => 'id', + * 'comment' => 'no-rls', // Foreign key comment – used to explicitly enable/disable RLS + * 'nullable' => false, // Whether the foreign key is nullable + * ]. + */ + protected function formatForeignKey(array $foreignKey, string $table): array + { + // $foreignKey is one of the foreign keys retrieved by $this->database->getSchemaBuilder()->getForeignKeys($table) + return [ + 'foreignKey' => $foreignKeyName = $foreignKey['columns'][0], + 'foreignTable' => $foreignKey['foreign_table'], + 'foreignId' => $foreignKey['foreign_columns'][0], + // Deleted in generateTrees() + 'comment' => $this->getComment($table, $foreignKeyName), + // Deleted in shortestPaths() + 'nullable' => $this->database->selectOne("SELECT is_nullable FROM information_schema.columns WHERE table_name = '{$table}' AND column_name = '{$foreignKeyName}'")->is_nullable === 'YES', + ]; + } + + /** Generates a query that creates a row-level security policy for the passed table. */ + protected function generateQuery(string $table, array $path): string + { + // Generate the SQL conditions recursively + $query = "CREATE POLICY {$table}_rls_policy ON {$table} USING (\n"; + $sessionTenantKey = config('tenancy.rls.session_variable_name'); + + foreach ($path as $index => $relation) { + $column = $relation['foreignKey']; + $table = $relation['foreignTable']; + $foreignKey = $relation['foreignId']; + + $indentation = str_repeat(' ', ($index + 1) * 4); + + $query .= $indentation; + + if ($index !== 0) { + // On first loop, we don't use a WHERE + $query .= 'WHERE '; + } + + if ($table === tenancy()->model()->getTable()) { + // Convert tenant key to text to match the session variable type + $query .= "{$column}::text = current_setting('{$sessionTenantKey}')\n"; + continue; + } + + $query .= "{$column} IN (\n"; + $query .= $indentation . " SELECT {$foreignKey}\n"; + $query .= $indentation . " FROM {$table}\n"; + } + + // Closing ) for each nested WHERE + // -1 because the last item is the tenant table reference which is not a nested where + for ($i = count($path) - 1; $i > 0; $i--) { + $query .= str_repeat(' ', $i * 4) . ")\n"; + } + + $query .= ');'; // closing for CREATE POLICY + + return $query; + } + + protected function getComment(string $tableName, string $columnName): string|null + { + $column = collect($this->database->getSchemaBuilder()->getColumns($tableName)) + ->filter(fn ($column) => $column['name'] === $columnName) + ->first(); + + return $column['comment'] ?? null; + } +} diff --git a/src/RLS/PolicyManagers/TraitRLSManager.php b/src/RLS/PolicyManagers/TraitRLSManager.php new file mode 100644 index 00000000..e83181dd --- /dev/null +++ b/src/RLS/PolicyManagers/TraitRLSManager.php @@ -0,0 +1,134 @@ + */ + public static Closure|null $modelDiscoveryOverride = null; + + /** + * Directories in which the manager will discover your models. + * Subdirectories of the specified directories are also scanned. + * + * For example, specifying 'app/Models' will discover all models in the 'app/Models' directory and all of its subdirectories. + * Specifying 'app/Models/*' will discover all models in the subdirectories of 'app/Models' (+ their subdirectories), + * but not the models present directly in the 'app/Models' directory. + */ + public static array $modelDirectories = ['app/Models']; + + /** + * Scope queries of all tenant models using RLS by default. + * + * To use RLS scoping only for some models, you can keep this disabled and + * make the models of your choice implement the RLSModel interface. + */ + public static bool $implicitRLS = false; + + /** @var array> */ + public static array $excludedModels = []; + + public function generateQueries(): array + { + $queries = []; + + foreach ($this->getModels() as $model) { + $table = $model->getTable(); + + if ($this->modelBelongsToTenant($model)) { + $queries[$table] = $this->generateDirectRLSPolicyQuery($model); + } + + if ($this->modelBelongsToTenantIndirectly($model)) { + $parentRelationship = $model->{$model->getRelationshipToPrimaryModel()}(); + + $queries[$table] = $this->generateIndirectRLSPolicyQuery($model, $parentRelationship); + } + } + + return $queries; + } + + protected function generateDirectRLSPolicyQuery(Model $model): string + { + $table = $model->getTable(); + $tenantKeyColumn = tenancy()->tenantKeyColumn(); + $sessionTenantKey = config('tenancy.rls.session_variable_name'); + + return <<getTable(); + $parent = $parentRelationship->getModel(); + $tenantKeyColumn = $parent->tenant()->getForeignKeyName(); + $sessionTenantKey = config('tenancy.rls.session_variable_name'); + + return <<getForeignKeyName()} IN ( + SELECT {$parent->getKeyName()} + FROM {$parent->getTable()} + WHERE {$tenantKeyColumn}::text = current_setting('{$sessionTenantKey}') + ) + ); + SQL; + } + + /** + * Discover and retrieve all models. + * + * Models are either discovered in the directories specified in static::$modelDirectories (by default), + * or by a custom closure specified in static::$modelDiscoveryOverride. + * + * @return array<\Illuminate\Database\Eloquent\Model> + */ + public function getModels(): array + { + if (static::$modelDiscoveryOverride) { + return (static::$modelDiscoveryOverride)(); + } + + $modelFiles = Finder::create()->files()->name('*.php')->in(static::$modelDirectories); + + return array_filter(array_map(function (SplFileInfo $file) { + $fileContents = str($file->getContents()); + $class = $fileContents->after("\nclass ")->before("\n")->explode(' ')->first(); + + if ($fileContents->contains('namespace ')) { + try { + return ($fileContents->after('namespace ')->before(';')->toString() . '\\' . $class)::make(); + } catch (\Throwable $th) { + // Skip non-instantiable classes – we only care about models, and those are instantiable + } + } + + return null; + }, iterator_to_array($modelFiles)), fn (object|null $object) => $object instanceof Model && ! in_array($object::class, static::$excludedModels)); + } + + public function modelBelongsToTenant(Model $model): bool + { + return in_array(BelongsToTenant::class, class_uses_recursive($model::class)); + } + + public function modelBelongsToTenantIndirectly(Model $model): bool + { + return in_array(BelongsToPrimaryModel::class, class_uses_recursive($model::class)); + } +} diff --git a/src/Tenancy.php b/src/Tenancy.php index 8b30aa39..c1c62916 100644 --- a/src/Tenancy.php +++ b/src/Tenancy.php @@ -9,13 +9,14 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Traits\Macroable; use Stancl\Tenancy\Concerns\DealsWithRouteContexts; +use Stancl\Tenancy\Concerns\ManagesRLSPolicies; use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByIdException; class Tenancy { - use Macroable, DealsWithRouteContexts; + use Macroable, DealsWithRouteContexts, ManagesRLSPolicies; /** * The current tenant. diff --git a/src/TenancyServiceProvider.php b/src/TenancyServiceProvider.php index 6f68a715..30dd8be0 100644 --- a/src/TenancyServiceProvider.php +++ b/src/TenancyServiceProvider.php @@ -94,6 +94,7 @@ class TenancyServiceProvider extends ServiceProvider Commands\MigrateFresh::class, Commands\ClearPendingTenants::class, Commands\CreatePendingTenants::class, + Commands\CreateUserWithRLSPolicies::class, ]); $this->app->extend(FreshCommand::class, function ($_, $app) { diff --git a/tests/DatabaseUsersTest.php b/tests/DatabaseUsersTest.php index ce16f9f1..d10aca57 100644 --- a/tests/DatabaseUsersTest.php +++ b/tests/DatabaseUsersTest.php @@ -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'], +]); diff --git a/tests/ManualModeTest.php b/tests/ManualModeTest.php index fe1ba9a6..f9983cf7 100644 --- a/tests/ManualModeTest.php +++ b/tests/ManualModeTest.php @@ -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(); diff --git a/tests/RLS/Etc/Article.php b/tests/RLS/Etc/Article.php new file mode 100644 index 00000000..08a0f191 --- /dev/null +++ b/tests/RLS/Etc/Article.php @@ -0,0 +1,15 @@ +belongsTo(Post::class, 'post_id'); + } +} diff --git a/tests/RLS/Etc/Post.php b/tests/RLS/Etc/Post.php new file mode 100644 index 00000000..978eb0e6 --- /dev/null +++ b/tests/RLS/Etc/Post.php @@ -0,0 +1,25 @@ +hasMany(Comment::class, 'post_id'); + } +} diff --git a/tests/RLS/PolicyTest.php b/tests/RLS/PolicyTest.php new file mode 100644 index 00000000..dd7c502d --- /dev/null +++ b/tests/RLS/PolicyTest.php @@ -0,0 +1,197 @@ + 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(<<id])->id; + + expect(fn () => DB::insert(<<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(<<id, $authorId]))->toThrow(QueryException::class); +})->with([ + TableRLSManager::class, + TraitRLSManager::class, +]); diff --git a/tests/RLS/TableManagerTest.php b/tests/RLS/TableManagerTest.php new file mode 100644 index 00000000..ef1ebf68 --- /dev/null +++ b/tests/RLS/TableManagerTest.php @@ -0,0 +1,705 @@ + 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([ + << [ + [ + '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( + <<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 = []; +} diff --git a/tests/RLS/TraitManagerTest.php b/tests/RLS/TraitManagerTest.php new file mode 100644 index 00000000..7a7dd37a --- /dev/null +++ b/tests/RLS/TraitManagerTest.php @@ -0,0 +1,328 @@ + 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( + <<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'); + } +} diff --git a/tests/SingleDatabaseTenancyTest.php b/tests/SingleDatabaseTenancyTest.php index e51be060..c71e6d38 100644 --- a/tests/SingleDatabaseTenancyTest.php +++ b/tests/SingleDatabaseTenancyTest.php @@ -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 diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index 9841fbec..43196ec2 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -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] ]); diff --git a/tests/TestCase.php b/tests/TestCase.php index ba706d26..f9a2d357 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -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); diff --git a/tests/UniversalRouteTest.php b/tests/UniversalRouteTest.php index 16c45a1e..2c731881 100644 --- a/tests/UniversalRouteTest.php +++ b/tests/UniversalRouteTest.php @@ -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;