1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2025-12-12 06:44:04 +00:00

Postgres RLS + permission controlled database managers (#33)

This PR adds Postgres RLS (trait manager + table manager approach) and permission controlled managers for PostgreSQL.

---------

Co-authored-by: lukinovec <lukinovec@gmail.com>
Co-authored-by: PHP CS Fixer <phpcsfixer@example.com>
This commit is contained in:
Samuel Štancl 2024-04-24 22:32:49 +02:00 committed by GitHub
parent 34297d3e1a
commit 7317d2638a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 2511 additions and 112 deletions

View file

@ -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

View file

@ -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'"`.

View file

@ -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.
*

View file

@ -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');

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Bootstrappers;
use Illuminate\Contracts\Config\Repository;
use Illuminate\Database\DatabaseManager;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Database\DatabaseManager as TenantConnectionManager;
/**
* When initializing tenancy, use the tenant connection with the configured RLS user credentials
* and set the configured session variable to the current tenant's key.
*
* When ending tenancy, reset the session variable (to invalidate the connection)
* and switch back to the central connection again.
*
* This bootstrapper is intended to be used with Postgres RLS.
*
* @see \Stancl\Tenancy\Commands\CreateUserWithRLSPolicies
*/
class PostgresRLSBootstrapper implements TenancyBootstrapper
{
public function __construct(
protected Repository $config,
protected DatabaseManager $database,
protected TenantConnectionManager $tenantConnectionManager,
) {}
public function bootstrap(Tenant $tenant): void
{
$this->connectToTenant();
$tenantSessionKey = $this->config->get('tenancy.rls.session_variable_name');
$this->database->statement("SET {$tenantSessionKey} = '{$tenant->getTenantKey()}'");
}
public function revert(): void
{
$this->database->statement("RESET {$this->config->get('tenancy.rls.session_variable_name')}");
$this->tenantConnectionManager->reconnectToCentral();
}
protected function connectToTenant(): void
{
$centralConnection = $this->config->get('tenancy.database.central_connection');
$this->tenantConnectionManager->purgeTenantConnection();
$tenantConnection = array_merge($this->config->get('database.connections.' . $centralConnection), [
'username' => $this->config->get('tenancy.rls.user.username'),
'password' => $this->config->get('tenancy.rls.user.password'),
]);
$this->config['database.connections.tenant'] = $tenantConnection;
$this->tenantConnectionManager->setDefaultConnection('tenant');
}
}

View file

@ -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;
}
}

View file

@ -0,0 +1,211 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Stancl\Tenancy\Database\Contracts\TenantWithDatabase;
use Stancl\Tenancy\Database\DatabaseConfig;
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQLSchemaManager;
use Stancl\Tenancy\RLS\PolicyManagers\RLSPolicyManager;
/**
* Creates RLS policies for tables of models related to the tenants table.
*
* This command is used with Postgres + single-database tenancy, specifically when using RLS.
*/
class CreateUserWithRLSPolicies extends Command
{
protected $signature = 'tenants:rls {--force= : Create RLS policies even if they already exist.}';
protected $description = "Creates RLS policies for all tables related to the tenant table. Also creates the RLS user if it doesn't exist yet";
public function handle(PermissionControlledPostgreSQLSchemaManager $manager): int
{
$username = config('tenancy.rls.user.username');
$password = config('tenancy.rls.user.password');
if ($username === null || $password === null) {
$this->components->error('The RLS user credentials are not set in the "tenancy.rls.user" config.');
return Command::FAILURE;
}
$this->components->info(
$manager->createUser($this->makeDatabaseConfig($manager, $username, $password))
? "RLS user '{$username}' has been created."
: "RLS user '{$username}' already exists."
);
$this->createTablePolicies();
return Command::SUCCESS;
}
protected function enableRLS(string $table): void
{
// Enable RLS scoping on the table (without this, queries won't be scoped using RLS)
DB::statement("ALTER TABLE {$table} ENABLE ROW LEVEL SECURITY");
/**
* Force RLS scoping on the table, so that the table owner users
* don't bypass the scoping table owners bypass RLS by default.
*
* E.g. when using a custom implementation where you create tables as the RLS user,
* the queries won't be scoped for the RLS user unless we force the RLS scoping using this query.
*/
DB::statement("ALTER TABLE {$table} FORCE ROW LEVEL SECURITY");
}
/**
* Create a DatabaseConfig instance for the RLS user,
* so that the user can get created using $manager->createUser($databaseConfig).
*/
protected function makeDatabaseConfig(
PermissionControlledPostgreSQLSchemaManager $manager,
string $username,
string $password,
): DatabaseConfig {
/** @var TenantWithDatabase $tenantModel */
$tenantModel = tenancy()->model();
// Use a temporary DatabaseConfig instance to set the host connection
$temporaryDbConfig = $tenantModel->database();
$temporaryDbConfig->purgeHostConnection();
$tenantHostConnectionName = $temporaryDbConfig->getTenantHostConnectionName();
config(["database.connections.{$tenantHostConnectionName}" => $temporaryDbConfig->hostConnection()]);
// Use the tenant host connection in the manager
$manager->setConnection($tenantModel->database()->getTenantHostConnectionName());
// Set the database name (= central schema name/search_path in this case), username, and password
$tenantModel->setInternal('db_name', $manager->database()->getConfig('search_path'));
$tenantModel->setInternal('db_username', $username);
$tenantModel->setInternal('db_password', $password);
return $tenantModel->database();
}
protected function createTablePolicies(): void
{
/** @var RLSPolicyManager $rlsPolicyManager */
$rlsPolicyManager = app(config('tenancy.rls.manager'));
$rlsQueries = $rlsPolicyManager->generateQueries();
$zombiePolicies = $this->dropZombiePolicies(array_keys($rlsQueries));
if ($zombiePolicies > 0) {
$this->components->warn("Dropped {$zombiePolicies} zombie RLS policies.");
}
$createdPolicies = [];
foreach ($rlsQueries as $table => $query) {
[$hash, $policyQuery] = $this->hashPolicy($query);
$expectedName = $table . '_rls_policy_' . $hash;
$tableRLSPolicy = $this->findTableRLSPolicy($table);
$olderPolicyExists = $tableRLSPolicy && $tableRLSPolicy->policyname !== $expectedName;
// Drop the policy if an outdated version exists
// or if it exists (even in the current form) and the --force option is used
$dropPolicy = $olderPolicyExists || ($tableRLSPolicy && $this->option('force'));
if ($tableRLSPolicy && $dropPolicy) {
DB::statement("DROP POLICY {$tableRLSPolicy->policyname} ON {$table}");
$this->components->info("RLS policy for table '{$table}' dropped.");
}
// Create RLS policy if the table doesn't have it or if the --force option is used
$createPolicy = $dropPolicy || ! $tableRLSPolicy || $this->option('force');
if ($createPolicy) {
DB::statement($policyQuery);
$this->enableRLS($table);
$createdPolicies[] = $table . " ($hash)";
} else {
$this->components->info("Table '{$table}' already has an up to date RLS policy.");
}
}
if (! empty($createdPolicies)) {
$managerName = str($rlsPolicyManager::class)->afterLast('\\')->toString();
$this->components->info("RLS policies created for tables (using {$managerName}):");
$this->components->bulletList($createdPolicies);
$this->components->info('RLS policies updated successfully.');
} else {
$this->components->info('All RLS policies are up to date.');
}
}
/** @return \stdClass|null */
protected function findTableRLSPolicy(string $table): object|null
{
return DB::selectOne(<<<SQL
SELECT * FROM pg_policies
WHERE tablename = '{$table}'
AND policyname LIKE '{$table}_rls_policy%';
SQL);
}
/**
* Converts a raw RLS policy query into a "versioned" query
* where the policy name is suffixed with a hash of the policy body.
*
* Returns the hash and the versioned query as a tuple.
*
* @return array{string, string}
*/
public function hashPolicy(string $query): array
{
$lines = explode("\n", $query);
// We split the query into the first line, the last line, and the actual body in between
$firstLine = array_shift($lines);
$lastLine = array_pop($lines);
$body = implode("\n", $lines);
// We update the policy name on the first line to contain a hash of the policy body
// to keep track of the version of the policy
$hash = substr(sha1($body), 0, 6);
$firstLine = str_replace('_policy ON ', "_policy_{$hash} ON ", $firstLine);
$policyQuery = $firstLine . "\n" . $body . "\n" . $lastLine;
return [$hash, $policyQuery];
}
/**
* Here we handle an edge case where a table may have an existing RLS policy
* but is not included in $rlsQueries, this can happen e.g. when changing $scopeByDefault.
* For these tables -- that have an existing policy but now SHOULDN'T have one -- we drop
* the existing policies.
*/
protected function dropZombiePolicies(array $tablesThatShouldHavePolicies): int
{
/** @var \stdClass[] $tablesWithRLSPolicies */
$tablesWithRLSPolicies = DB::select("SELECT tablename, policyname FROM pg_policies WHERE policyname LIKE '%_rls_policy%'");
$zombies = 0;
foreach ($tablesWithRLSPolicies as $table) {
if (! in_array($table->tablename, $tablesThatShouldHavePolicies, true)) {
DB::statement("DROP POLICY {$table->policyname} ON {$table->tablename}");
$this->components->warn("RLS policy for table '{$table->tablename}' dropped (zombie).");
$zombies++;
}
}
return $zombies;
}
}

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Concerns;
use Illuminate\Support\Facades\DB;
use stdClass;
/**
* Used for easily dropping RLS policies on tables, primarily in migrations.
*/
trait ManagesRLSPolicies
{
/** @return string[] */
public static function getRLSPolicies(string $table): array
{
return array_map(
fn (stdClass $policy) => $policy->policyname,
DB::select("SELECT policyname FROM pg_policies WHERE tablename = '{$table}' AND policyname LIKE '%_rls_policy%'")
);
}
public static function dropRLSPolicies(string $table): int
{
$policies = static::getRLSPolicies($table);
foreach ($policies as $policy) {
DB::statement('DROP POLICY ? ON ?', [$policy, $table]);
}
return count($policies);
}
}

View file

@ -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
{
$implicitRLS = config('tenancy.rls.manager') === TraitRLSManager::class && TraitRLSManager::$implicitRLS;
if (! $implicitRLS && ! (new static) instanceof RLSModel) {
static::addGlobalScope(new ParentModelScope);
}
}
}

View file

@ -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);
}
}
});
}
}

View file

@ -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);
}
}

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Database\Concerns;
use Stancl\Tenancy\Tenancy;
trait FillsCurrentTenant
{
public static function bootFillsCurrentTenant(): void
{
static::creating(function ($model) {
if (! $model->getAttribute(Tenancy::tenantKeyColumn()) && ! $model->relationLoaded('tenant')) {
if (tenancy()->initialized) {
$model->setAttribute(Tenancy::tenantKeyColumn(), tenant()->getTenantKey());
$model->setRelation('tenant', tenant());
}
}
});
}
}

View file

@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Database\Concerns;
use Stancl\Tenancy\Database\DatabaseConfig;
use Stancl\Tenancy\Database\TenantDatabaseManagers\TenantDatabaseManager;
/**
* @method \Illuminate\Database\Connection database()
* @mixin TenantDatabaseManager
*/
trait ManagesPostgresUsers
{
/**
* Grant database/schema and table permissions to the user whose credentials are stored in the passed DatabaseConfig.
*
* With schema manager, the schema name is stored in the 'search_path' key of the connection config,
* but it's still accessible using $databaseConfig->getName().
*/
abstract protected function grantPermissions(DatabaseConfig $databaseConfig): bool;
public function createUser(DatabaseConfig $databaseConfig): bool
{
/** @var string $username */
$username = $databaseConfig->getUsername();
$password = $databaseConfig->getPassword();
$createUser = ! $this->userExists($username);
if ($createUser) {
$this->database()->statement("CREATE USER \"{$username}\" LOGIN PASSWORD '{$password}'");
}
$this->grantPermissions($databaseConfig);
return $createUser;
}
public function deleteUser(DatabaseConfig $databaseConfig): bool
{
/** @var TenantDatabaseManager $this */
// Tenant DB username
$username = $databaseConfig->getUsername();
// Tenant host connection config
$connectionName = $this->database()->getConfig('name');
$centralDatabase = $this->database()->getConfig('database');
// Set the DB/schema name to the tenant DB/schema name
config()->set(
"database.connections.{$connectionName}",
$this->makeConnectionConfig($this->database()->getConfig(), $databaseConfig->getName()),
);
// Connect to the tenant DB/schema
$this->database()->reconnect();
// Delete all database objects owned by the user (privileges, tables, views, etc.)
// Postgres users cannot be deleted unless we delete all objects owned by it first
$this->database()->statement("DROP OWNED BY \"{$username}\"");
// Delete the user
$userDeleted = $this->database()->statement("DROP USER \"{$username}\"");
config()->set(
"database.connections.{$connectionName}",
$this->makeConnectionConfig($this->database()->getConfig(), $centralDatabase),
);
// Reconnect to the central database
$this->database()->reconnect();
return $userDeleted;
}
public function userExists(string $username): bool
{
return (bool) $this->database()->selectOne("SELECT usename FROM pg_user WHERE usename = '{$username}'");
}
}

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Database\Concerns;
/**
* Interface indicating that the queries of the model it's used on
* get scoped using RLS (instead of the global TenantScope).
*
* All models whose queries you want to scope using RLS
* need to implement this interface if RLS scoping is explicit (= when TraitRLSManager::$implicitRLS is false).
* The models also have to use one of the single-database traits.
*
* Used with Postgres RLS via TraitRLSManager.
*
* @see \Stancl\Tenancy\RLS\PolicyManagers\TraitRLSManager
* @see BelongsToTenant
* @see BelongsToPrimaryModel
*/
interface RLSModel {}

View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Database\Exceptions;
use Exception;
/**
* @see \Stancl\Tenancy\RLS\PolicyManagers\TableRLSManager
*/
class RecursiveRelationshipException extends Exception
{
public function __construct(string|null $message = null)
{
parent::__construct($message ?? "Table's foreign key referenced multiple times in the same path.");
}
}

View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Database\Exceptions;
use Exception;
/**
* @see \Stancl\Tenancy\RLS\PolicyManagers\TraitRLSManager
*/
class TableNotRelatedToTenantException extends Exception
{
public function __construct(string $table)
{
parent::__construct("Table $table does not belong to a tenant directly or through another table.");
}
}

View file

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Database\TenantDatabaseManagers;
use Stancl\Tenancy\Database\Concerns\CreatesDatabaseUsers;
use Stancl\Tenancy\Database\Concerns\ManagesPostgresUsers;
use Stancl\Tenancy\Database\Contracts\ManagesDatabaseUsers;
use Stancl\Tenancy\Database\DatabaseConfig;
class PermissionControlledPostgreSQLDatabaseManager extends PostgreSQLDatabaseManager implements ManagesDatabaseUsers
{
use CreatesDatabaseUsers, ManagesPostgresUsers;
protected function grantPermissions(DatabaseConfig $databaseConfig): bool
{
// Tenant DB config
$database = $databaseConfig->getName();
$username = $databaseConfig->getUsername();
$schema = $databaseConfig->connection()['search_path'];
// Host config
$connectionName = $this->database()->getConfig('name');
$centralDatabase = $this->database()->getConfig('database');
$this->database()->statement("GRANT CONNECT ON DATABASE \"{$database}\" TO \"{$username}\"");
// Connect to tenant database
config(["database.connections.{$connectionName}.database" => $database]);
$this->database()->reconnect();
// Grant permissions to create and use tables in the configured schema ("public" by default) to the user
$this->database()->statement("GRANT USAGE, CREATE ON SCHEMA {$schema} TO \"{$username}\"");
// Grant permissions to use sequences in the current schema to the user
$this->database()->statement("GRANT USAGE ON ALL SEQUENCES IN SCHEMA {$schema} TO \"{$username}\"");
// Reconnect to central database
config(["database.connections.{$connectionName}.database" => $centralDatabase]);
$this->database()->reconnect();
return true;
}
}

View file

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Database\TenantDatabaseManagers;
use Illuminate\Support\Facades\DB;
use Stancl\Tenancy\Database\Concerns\CreatesDatabaseUsers;
use Stancl\Tenancy\Database\Concerns\ManagesPostgresUsers;
use Stancl\Tenancy\Database\Contracts\ManagesDatabaseUsers;
use Stancl\Tenancy\Database\DatabaseConfig;
class PermissionControlledPostgreSQLSchemaManager extends PostgreSQLSchemaManager implements ManagesDatabaseUsers
{
use CreatesDatabaseUsers, ManagesPostgresUsers;
protected function grantPermissions(DatabaseConfig $databaseConfig): bool
{
// Tenant DB config
$username = $databaseConfig->getUsername();
$schema = $databaseConfig->getName();
// Central database name
$database = DB::connection(config('tenancy.database.central_connection'))->getDatabaseName();
$this->database()->statement("GRANT CONNECT ON DATABASE {$database} TO \"{$username}\"");
$this->database()->statement("GRANT USAGE, CREATE ON SCHEMA \"{$schema}\" TO \"{$username}\"");
$this->database()->statement("GRANT USAGE ON ALL SEQUENCES IN SCHEMA \"{$schema}\" TO \"{$username}\"");
$tables = $this->database()->select("SELECT table_name FROM information_schema.tables WHERE table_schema = '{$schema}'");
// Grant permissions to any existing tables. This is used with RLS
// todo@samuel refactor this along with the todo in TenantDatabaseManager
// and move the grantPermissions() call inside the condition in `ManagesPostgresUsers::createUser()`
foreach ($tables as $table) {
$tableName = $table->table_name;
/** @var string $primaryKey */
$primaryKey = $this->database()->selectOne(<<<SQL
SELECT column_name
FROM information_schema.key_column_usage
WHERE table_name = '{$tableName}'
AND constraint_name LIKE '%_pkey'
SQL)->column_name;
// Grant all permissions for all existing tables
$this->database()->statement("GRANT ALL ON \"{$schema}\".\"{$tableName}\" TO \"{$username}\"");
// Grant permission to reference the primary key for the table
// The previous query doesn't grant the references privilege, so it has to be granted here
$this->database()->statement("GRANT REFERENCES (\"{$primaryKey}\") ON \"{$schema}\".\"{$tableName}\" TO \"{$username}\"");
}
return true;
}
}

View file

@ -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'");
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\RLS\PolicyManagers;
interface RLSPolicyManager
{
/**
* Generate queries that create row-level security policies for tables.
*
* @return string[]
*/
public function generateQueries(): array;
}

View file

@ -0,0 +1,260 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\RLS\PolicyManagers;
use Illuminate\Database\DatabaseManager;
use Stancl\Tenancy\Database\Exceptions\RecursiveRelationshipException;
// todo@samuel logical + structural refactor. the tree generation could use some dynamic programming optimizations
class TableRLSManager implements RLSPolicyManager
{
public static bool $scopeByDefault = true;
public function __construct(
protected DatabaseManager $database
) {}
public function generateQueries(array $trees = []): array
{
$queries = [];
foreach ($trees ?: $this->shortestPaths() as $table => $path) {
$queries[$table] = $this->generateQuery($table, $path);
}
return $queries;
}
/**
* Reduce trees to shortest paths (structured like ['table_foo' => $shortestPathForFoo, 'table_bar' => $shortestPathForBar]).
*
* For example:
*
* 'posts' => [
* [
* 'foreignKey' => 'tenant_id',
* 'foreignTable' => 'tenants',
* 'foreignId' => 'id'
* ],
* ],
* 'comments' => [
* [
* 'foreignKey' => 'post_id',
* 'foreignTable' => 'posts',
* 'foreignId' => 'id'
* ],
* [
* 'foreignKey' => 'tenant_id',
* 'foreignTable' => 'tenants',
* 'foreignId' => 'id'
* ],
* ],
*/
public function shortestPaths(array $trees = []): array
{
$reducedTrees = [];
foreach ($trees ?: $this->generateTrees() as $table => $tree) {
$reducedTrees[$table] = $this->findShortestPath($this->filterNonNullablePaths($tree) ?: $tree);
}
return $reducedTrees;
}
/**
* Generate trees of paths that lead to the tenants table
* for the foreign keys of all tables only the paths that lead to the tenants table are included.
*
* Also unset the 'comment' key from the retrieved path steps.
*/
public function generateTrees(): array
{
$trees = [];
$builder = $this->database->getSchemaBuilder();
// We loop through each table in the database
foreach ($builder->getTableListing() as $table) {
// For each table, we get a list of all foreign key columns
$foreignKeys = collect($builder->getForeignKeys($table))->map(function ($foreign) use ($table) {
return $this->formatForeignKey($foreign, $table);
});
// We loop through each foreign key column and find
// all possible paths that lead to the tenants table
foreach ($foreignKeys as $foreign) {
$paths = [];
$this->generatePaths($table, $foreign, $paths);
foreach ($paths as &$path) {
foreach ($path as &$step) {
unset($step['comment']);
}
}
if (count($paths)) {
$trees[$table][$foreign['foreignKey']] = $paths;
}
}
}
return $trees;
}
protected function generatePaths(string $table, array $foreign, array &$paths, array $currentPath = []): void
{
if (in_array($foreign['foreignTable'], array_column($currentPath, 'foreignTable'))) {
throw new RecursiveRelationshipException;
}
$currentPath[] = $foreign;
if ($foreign['foreignTable'] === tenancy()->model()->getTable()) {
$comments = array_column($currentPath, 'comment');
$pathCanUseRls = static::$scopeByDefault ?
! in_array('no-rls', $comments) :
! in_array('no-rls', $comments) && ! in_array(null, $comments);
if ($pathCanUseRls) {
// If the foreign table is the tenants table, add the current path to $paths
$paths[] = $currentPath;
}
} else {
// If not, recursively generate paths for the foreign table
foreach ($this->database->getSchemaBuilder()->getForeignKeys($foreign['foreignTable']) as $nextConstraint) {
$this->generatePaths($table, $this->formatForeignKey($nextConstraint, $foreign['foreignTable']), $paths, $currentPath);
}
}
}
/** Get tree's non-nullable paths. */
protected function filterNonNullablePaths(array $tree): array
{
$nonNullablePaths = [];
foreach ($tree as $foreignKey => $paths) {
foreach ($paths as $path) {
$pathIsNullable = false;
foreach ($path as $step) {
if ($step['nullable']) {
$pathIsNullable = true;
break;
}
}
if (! $pathIsNullable) {
$nonNullablePaths[$foreignKey][] = $path;
}
}
}
return $nonNullablePaths;
}
/** Find the shortest path in a tree and unset the 'nullable' key from the path steps. */
protected function findShortestPath(array $tree): array
{
$shortestPath = [];
foreach ($tree as $pathsForForeignKey) {
foreach ($pathsForForeignKey as $path) {
if (empty($shortestPath) || count($shortestPath) > count($path)) {
$shortestPath = $path;
foreach ($shortestPath as &$step) {
unset($step['nullable']);
}
}
}
}
return $shortestPath;
}
/**
* Formats the foreign key array retrieved by Postgres to a more readable format.
*
* Also provides information about whether the foreign key is nullable,
* and the foreign key column comment. These additional details are removed
* from the foreign keys/path steps before returning the final shortest paths.
*
* The 'comment' key gets deleted while generating the full trees (in generateTrees()),
* and the 'nullable' key gets deleted while generating the shortest paths (in findShortestPath()).
*
* [
* 'foreignKey' => 'tenant_id',
* 'foreignTable' => 'tenants',
* 'foreignId' => 'id',
* 'comment' => 'no-rls', // Foreign key comment used to explicitly enable/disable RLS
* 'nullable' => false, // Whether the foreign key is nullable
* ].
*/
protected function formatForeignKey(array $foreignKey, string $table): array
{
// $foreignKey is one of the foreign keys retrieved by $this->database->getSchemaBuilder()->getForeignKeys($table)
return [
'foreignKey' => $foreignKeyName = $foreignKey['columns'][0],
'foreignTable' => $foreignKey['foreign_table'],
'foreignId' => $foreignKey['foreign_columns'][0],
// Deleted in generateTrees()
'comment' => $this->getComment($table, $foreignKeyName),
// Deleted in shortestPaths()
'nullable' => $this->database->selectOne("SELECT is_nullable FROM information_schema.columns WHERE table_name = '{$table}' AND column_name = '{$foreignKeyName}'")->is_nullable === 'YES',
];
}
/** Generates a query that creates a row-level security policy for the passed table. */
protected function generateQuery(string $table, array $path): string
{
// Generate the SQL conditions recursively
$query = "CREATE POLICY {$table}_rls_policy ON {$table} USING (\n";
$sessionTenantKey = config('tenancy.rls.session_variable_name');
foreach ($path as $index => $relation) {
$column = $relation['foreignKey'];
$table = $relation['foreignTable'];
$foreignKey = $relation['foreignId'];
$indentation = str_repeat(' ', ($index + 1) * 4);
$query .= $indentation;
if ($index !== 0) {
// On first loop, we don't use a WHERE
$query .= 'WHERE ';
}
if ($table === tenancy()->model()->getTable()) {
// Convert tenant key to text to match the session variable type
$query .= "{$column}::text = current_setting('{$sessionTenantKey}')\n";
continue;
}
$query .= "{$column} IN (\n";
$query .= $indentation . " SELECT {$foreignKey}\n";
$query .= $indentation . " FROM {$table}\n";
}
// Closing ) for each nested WHERE
// -1 because the last item is the tenant table reference which is not a nested where
for ($i = count($path) - 1; $i > 0; $i--) {
$query .= str_repeat(' ', $i * 4) . ")\n";
}
$query .= ');'; // closing for CREATE POLICY
return $query;
}
protected function getComment(string $tableName, string $columnName): string|null
{
$column = collect($this->database->getSchemaBuilder()->getColumns($tableName))
->filter(fn ($column) => $column['name'] === $columnName)
->first();
return $column['comment'] ?? null;
}
}

View file

@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\RLS\PolicyManagers;
use Closure;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Stancl\Tenancy\Database\Concerns\BelongsToPrimaryModel;
use Stancl\Tenancy\Database\Concerns\BelongsToTenant;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;
class TraitRLSManager implements RLSPolicyManager
{
/** @var Closure: array<\Illuminate\Database\Eloquent\Model> */
public static Closure|null $modelDiscoveryOverride = null;
/**
* Directories in which the manager will discover your models.
* Subdirectories of the specified directories are also scanned.
*
* For example, specifying 'app/Models' will discover all models in the 'app/Models' directory and all of its subdirectories.
* Specifying 'app/Models/*' will discover all models in the subdirectories of 'app/Models' (+ their subdirectories),
* but not the models present directly in the 'app/Models' directory.
*/
public static array $modelDirectories = ['app/Models'];
/**
* Scope queries of all tenant models using RLS by default.
*
* To use RLS scoping only for some models, you can keep this disabled and
* make the models of your choice implement the RLSModel interface.
*/
public static bool $implicitRLS = false;
/** @var array<class-string<\Illuminate\Database\Eloquent\Model>> */
public static array $excludedModels = [];
public function generateQueries(): array
{
$queries = [];
foreach ($this->getModels() as $model) {
$table = $model->getTable();
if ($this->modelBelongsToTenant($model)) {
$queries[$table] = $this->generateDirectRLSPolicyQuery($model);
}
if ($this->modelBelongsToTenantIndirectly($model)) {
$parentRelationship = $model->{$model->getRelationshipToPrimaryModel()}();
$queries[$table] = $this->generateIndirectRLSPolicyQuery($model, $parentRelationship);
}
}
return $queries;
}
protected function generateDirectRLSPolicyQuery(Model $model): string
{
$table = $model->getTable();
$tenantKeyColumn = tenancy()->tenantKeyColumn();
$sessionTenantKey = config('tenancy.rls.session_variable_name');
return <<<SQL
CREATE POLICY {$table}_rls_policy ON {$table} USING (
{$tenantKeyColumn}::text = current_setting('{$sessionTenantKey}')
);
SQL;
}
protected function generateIndirectRLSPolicyQuery(Model $model, BelongsTo $parentRelationship): string
{
$table = $model->getTable();
$parent = $parentRelationship->getModel();
$tenantKeyColumn = $parent->tenant()->getForeignKeyName();
$sessionTenantKey = config('tenancy.rls.session_variable_name');
return <<<SQL
CREATE POLICY {$table}_rls_policy ON {$table} USING (
{$parentRelationship->getForeignKeyName()} IN (
SELECT {$parent->getKeyName()}
FROM {$parent->getTable()}
WHERE {$tenantKeyColumn}::text = current_setting('{$sessionTenantKey}')
)
);
SQL;
}
/**
* Discover and retrieve all models.
*
* Models are either discovered in the directories specified in static::$modelDirectories (by default),
* or by a custom closure specified in static::$modelDiscoveryOverride.
*
* @return array<\Illuminate\Database\Eloquent\Model>
*/
public function getModels(): array
{
if (static::$modelDiscoveryOverride) {
return (static::$modelDiscoveryOverride)();
}
$modelFiles = Finder::create()->files()->name('*.php')->in(static::$modelDirectories);
return array_filter(array_map(function (SplFileInfo $file) {
$fileContents = str($file->getContents());
$class = $fileContents->after("\nclass ")->before("\n")->explode(' ')->first();
if ($fileContents->contains('namespace ')) {
try {
return ($fileContents->after('namespace ')->before(';')->toString() . '\\' . $class)::make();
} catch (\Throwable $th) {
// Skip non-instantiable classes we only care about models, and those are instantiable
}
}
return null;
}, iterator_to_array($modelFiles)), fn (object|null $object) => $object instanceof Model && ! in_array($object::class, static::$excludedModels));
}
public function modelBelongsToTenant(Model $model): bool
{
return in_array(BelongsToTenant::class, class_uses_recursive($model::class));
}
public function modelBelongsToTenantIndirectly(Model $model): bool
{
return in_array(BelongsToPrimaryModel::class, class_uses_recursive($model::class));
}
}

View file

@ -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.

View file

@ -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) {

View file

@ -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,
],
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'],
]);
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
$tenant = Tenant::create([
'id' => 'foo' . Str::random(10),
]);
expect($tenant->database()->manager() instanceof MicrosoftSQLDatabaseManager)->toBeTrue();
tenancy()->initialize($tenant); // check if everything works
tenancy()->end();
config(['tenancy.database.managers.sqlsrv' => PermissionControlledMicrosoftSQLServerDatabaseManager::class]);
tenancy()->initialize($tenant); // check if everything works
expect($tenant->database()->manager() instanceof PermissionControlledMicrosoftSQLServerDatabaseManager)->toBeTrue();
expect(config('database.connections.tenant.username'))->toBe('sa'); // default user for the sqlsrv connection
});

15
tests/RLS/Etc/Article.php Normal file
View file

@ -0,0 +1,15 @@
<?php
namespace Stancl\Tenancy\Tests\RLS\Etc;
use Illuminate\Database\Eloquent\Model;
use Stancl\Tenancy\Database\Concerns\BelongsToTenant;
use Stancl\Tenancy\Database\Concerns\RLSModel;
/** Used for testing TraitRLSManager */
class Article extends Model implements RLSModel
{
use BelongsToTenant;
protected $guarded = [];
}

27
tests/RLS/Etc/Comment.php Normal file
View file

@ -0,0 +1,27 @@
<?php
namespace Stancl\Tenancy\Tests\RLS\Etc;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Stancl\Tenancy\Database\Concerns\BelongsToPrimaryModel;
use Stancl\Tenancy\Database\Concerns\RLSModel;
class Comment extends Model implements RLSModel
{
use BelongsToPrimaryModel;
public $guarded = [];
public $table = 'comments';
public function getRelationshipToPrimaryModel(): string
{
return 'post';
}
public function post(): BelongsTo
{
return $this->belongsTo(Post::class, 'post_id');
}
}

25
tests/RLS/Etc/Post.php Normal file
View file

@ -0,0 +1,25 @@
<?php
namespace Stancl\Tenancy\Tests\RLS\Etc;
use Illuminate\Database\Eloquent\Model;
use Stancl\Tenancy\Database\Concerns\RLSModel;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Stancl\Tenancy\Database\Concerns\BelongsToTenant;
/** Used for testing TraitRLSManager */
class Post extends Model implements RLSModel
{
use BelongsToTenant;
public $table = 'posts';
public $timestamps = false;
protected $guarded = [];
public function comments(): HasMany
{
return $this->hasMany(Comment::class, 'post_id');
}
}

197
tests/RLS/PolicyTest.php Normal file
View file

@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Stancl\Tenancy\Tests\Etc\Tenant;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Schema;
use Stancl\Tenancy\Events\TenancyEnded;
use Illuminate\Database\Schema\Blueprint;
use Stancl\Tenancy\Tests\RLS\Etc\Article;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Commands\CreateUserWithRLSPolicies;
use Stancl\Tenancy\RLS\PolicyManagers\TableRLSManager;
use Stancl\Tenancy\RLS\PolicyManagers\TraitRLSManager;
use Stancl\Tenancy\Bootstrappers\PostgresRLSBootstrapper;
beforeEach(function () {
TraitRLSManager::$excludedModels = [Article::class];
TraitRLSManager::$modelDirectories = [__DIR__ . '/Etc'];
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
DB::purge($centralConnection = config('tenancy.database.central_connection'));
config(['database.connections.' . $centralConnection => config('database.connections.pgsql')]);
config(['tenancy.models.tenant_key_column' => 'tenant_id']);
config(['tenancy.models.tenant' => Tenant::class]);
config(['tenancy.bootstrappers' => [PostgresRLSBootstrapper::class]]);
config(['tenancy.rls.user' => [
'username' => 'username',
'password' => 'password',
]]);
// Turn implicit RLS scoping on
TraitRLSManager::$implicitRLS = true;
pest()->artisan('migrate:fresh', [
'--force' => true,
'--path' => __DIR__ . '/../../assets/migrations',
'--realpath' => true,
]);
Schema::create('authors', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('tenant_id');
$table->foreign('tenant_id')->comment('rls')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade');
$table->timestamps();
});
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->string('text');
// Multiple foreign keys to test if the table manager generates the paths correctly
// Leads to the tenants table
$table->string('tenant_id')->nullable();
$table->foreign('tenant_id')->comment('rls')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade');
// Leads to the tenants table shortest path (because the tenant key column is nullable excluded when choosing the shortest path)
$table->foreignId('author_id')->comment('rls')->constrained('authors');
$table->timestamps();
});
Schema::create('comments', function (Blueprint $table) {
$table->id();
$table->string('text');
$table->foreignId('post_id')->comment('rls')->constrained('posts')->onUpdate('cascade')->onDelete('cascade');
$table->timestamps();
});
});
test('postgres user gets created using the rls command', function(string $manager) {
config(['tenancy.rls.manager' => $manager]);
pest()->artisan('tenants:rls');
$name = config('tenancy.rls.user.username');
expect(count(DB::select("SELECT usename FROM pg_user WHERE usename = '$name'")))->toBe(1);
})->with([
TableRLSManager::class,
TraitRLSManager::class,
]);
test('rls command creates rls policies only for tables that do not have them', function (string $manager) {
config(['tenancy.rls.manager' => $manager]);
// Posts + comments (2 tables) with trait manager (authors doesn't have a model with the relevant trait)
// Posts + comments + authors (3 tables) with table manager
$tableCount = $manager === TraitRLSManager::class ? 2 : 3;
$policyCount = fn () => count(DB::select('SELECT * FROM pg_policies'));
expect($policyCount())->toBe(0);
pest()->artisan('tenants:rls');
expect($policyCount())->toBe($tableCount);
$policies = DB::select('SELECT * FROM pg_policies');
DB::statement("DROP POLICY {$policies[0]->policyname} ON {$policies[0]->tablename}");
expect($policyCount())->toBe($tableCount - 1); // one deleted
pest()->artisan('tenants:rls');
// back to original count
expect($policyCount())->toBe($tableCount);
expect(DB::select('SELECT * FROM pg_policies'))->toHaveCount(count($policies));
})->with([
TableRLSManager::class,
TraitRLSManager::class,
]);
test('rls command recreates outdated policies', function (string $manager) {
config(['tenancy.rls.manager' => $manager]);
// "test" being an outdated hash
DB::statement('CREATE POLICY posts_rls_policy_test ON posts');
pest()->artisan('tenants:rls');
expect(DB::selectOne("SELECT policyname FROM pg_policies WHERE tablename = 'posts'")->policyname)
->toStartWith('posts_rls_policy_')
->not()->toBe('posts_rls_policy_test');
})->with([
TableRLSManager::class,
TraitRLSManager::class,
]);
test('rls command recreates policies if the force option is passed', function (string $manager) {
config(['tenancy.rls.manager' => $manager]);
/** @var CreateUserWithRLSPolicies $policyCreationCommand */
$policyCreationCommand = app(CreateUserWithRLSPolicies::class);
$hash = $policyCreationCommand->hashPolicy(app(config('tenancy.rls.manager'))->generateQueries()['posts'])[0];
$policyNameWithHash = "posts_rls_policy_{$hash}";
DB::enableQueryLog();
DB::statement($policyCreationQuery = "CREATE POLICY {$policyNameWithHash} ON posts");
pest()->artisan('tenants:rls', ['--force' => true]);
$postsPolicyCreationQueries = collect(DB::getQueryLog())
->pluck('query')
->filter(fn (string $query) => str($query)->contains($policyCreationQuery));
expect($postsPolicyCreationQueries)->toHaveCount(2);
})->with([
TableRLSManager::class,
TraitRLSManager::class,
]);
test('queries will stop working when the tenant session variable is not set', function(string $manager) {
config(['tenancy.rls.manager' => $manager]);
$sessionVariableName = config('tenancy.rls.session_variable_name');
$tenant = Tenant::create();
pest()->artisan('tenants:rls');
tenancy()->initialize($tenant);
// The session variable is set correctly
// Creating a record for the current tenant should work
$authorId = DB::selectOne(<<<SQL
INSERT INTO authors (name, tenant_id)
VALUES ('author1', ?)
RETURNING id
SQL, [$tenant->id])->id;
expect(fn () => DB::insert(<<<SQL
INSERT INTO posts (text, tenant_id, author_id)
VALUES ('post1', ?, ?)
SQL, [$tenant->id, $authorId]))->not()->toThrow(Exception::class);
DB::statement("RESET {$sessionVariableName}");
// Throws RLS violation exception
// The session variable is not set to the current tenant key
expect(fn () => DB::insert(<<<SQL
INSERT INTO posts (text, tenant_id, author_id)
VALUES ('post2', ?, ?)
SQL, [$tenant->id, $authorId]))->toThrow(QueryException::class);
})->with([
TableRLSManager::class,
TraitRLSManager::class,
]);

View file

@ -0,0 +1,705 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\DB;
use Stancl\Tenancy\Tests\Etc\Tenant;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\QueryException;
use Stancl\Tenancy\Events\TenancyEnded;
use Illuminate\Database\Schema\Blueprint;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Stancl\Tenancy\Commands\CreateUserWithRLSPolicies;
use Stancl\Tenancy\RLS\PolicyManagers\TableRLSManager;
use Stancl\Tenancy\Bootstrappers\PostgresRLSBootstrapper;
use Stancl\Tenancy\Database\Exceptions\RecursiveRelationshipException;
beforeEach(function () {
TableRLSManager::$scopeByDefault = true;
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
DB::purge($centralConnection = config('tenancy.database.central_connection'));
config(['database.connections.' . $centralConnection => config('database.connections.pgsql')]);
config(['tenancy.models.tenant_key_column' => 'tenant_id']);
config(['tenancy.models.tenant' => Tenant::class]);
config(['tenancy.rls.manager' => TableRLSManager::class]);
config(['tenancy.rls.user.username' => 'username']);
config(['tenancy.rls.user.password' => 'password']);
config(['tenancy.bootstrappers' => [PostgresRLSBootstrapper::class]]);
pest()->artisan('migrate:fresh', [
'--force' => true,
'--path' => __DIR__ . '/../../assets/migrations',
'--realpath' => true,
]);
Schema::create('authors', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('tenant_id')->comment('rls');
$table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade');
$table->timestamps();
});
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('tenant_id')->comment('no-rls'); // not scoped
$table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade');
$table->timestamps();
});
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->string('text');
// Multiple foreign keys to test if the table manager generates the paths correctly
// Leads to the tenants table, BUT is nullable
$table->string('tenant_id')->nullable()->comment('rls');
$table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade');
// Leads to the tenants table shortest path (because the tenant key column is nullable excluded when choosing the shortest path)
$table->foreignId('author_id')->comment('rls')->constrained('authors');
// Doesn't lead to the tenants table because of a no-rls comment further down the path should get excluded from paths entirely
$table->foreignId('category_id')->comment('rls')->nullable()->constrained('categories');
$table->timestamps();
});
Schema::create('comments', function (Blueprint $table) {
$table->id();
$table->string('text');
$table->foreignId('post_id')->comment('rls')->constrained('posts')->onUpdate('cascade')->onDelete('cascade');
$table->timestamps();
});
// Related to tenants table only through a no-rls path
// Exists to see if the manager correctly excludes it from the paths
Schema::create('reactions', function (Blueprint $table) {
$table->id();
$table->boolean('like')->default(true);
$table->foreignId('comment_id')->comment('no-rls')->constrained('comments')->onUpdate('cascade')->onDelete('cascade');
$table->timestamps();
});
// Not related to the tenants table in any way
// Exists to check that the manager doesn't generate paths for models not related to the tenants table
Schema::create('articles', function (Blueprint $table) {
$table->id();
$table->string('text');
$table->timestamps();
});
});
test('correct rls policies get created with the correct hash using table manager', function() {
$manager = app(config('tenancy.rls.manager'));
$tables = [
'authors',
'posts',
'comments',
// The following tables will get completely excluded from policy generation
// Because they are only related to the tenants table by paths using the 'no-rls' comment
// 'reactions',
// 'categories',
];
$getRLSPolicies = fn () => DB::select('SELECT policyname, tablename FROM pg_policies');
$getRLSTables = fn () => collect($tables)->map(fn ($table) => DB::select('SELECT relname, relforcerowsecurity FROM pg_class WHERE oid = ?::regclass', [$table]))->collapse();
// Drop all existing policies to check if the command creates policies for multiple tables
foreach ($getRLSPolicies() as $policy) {
DB::statement("DROP POLICY IF EXISTS {$policy->policyname} ON {$policy->tablename}");
}
expect($getRLSPolicies())->toHaveCount(0);
pest()->artisan('tenants:rls');
// Check if all tables related to the tenant have RLS policies
expect($policies = $getRLSPolicies())->toHaveCount(count($tables));
expect($rlsTables = $getRLSTables())->toHaveCount(count($tables));
foreach ($rlsTables as $table) {
expect($tables)->toContain($table->relname);
expect($table->relforcerowsecurity)->toBeTrue();
}
// Check that the policies get suffixed with the correct hash
$queries = $manager->generateQueries();
expect(array_keys($queries))->toEqualCanonicalizing($tables);
expect(array_keys($queries))->not()->toContain('articles');
/** @var CreateUserWithRLSPolicies $policyCreationCommand */
$policyCreationCommand = app(CreateUserWithRLSPolicies::class);
foreach ($queries as $table => $query) {
$policy = collect($policies)->filter(fn (object $policy) => $policy->tablename === $table)->first();
$hash = $policyCreationCommand->hashPolicy($query)[0];
$policyNameWithHash = "{$table}_rls_policy_{$hash}";
expect($tables)->toContain($policy->tablename);
expect($policy->policyname)->toBe($policyNameWithHash);
}
});
test('queries are correctly scoped using RLS', function() {
// 3-levels deep relationship
Schema::create('notes', function (Blueprint $table) {
$table->id();
$table->string('text')->default('foo');
// no rls comment needed, $scopeByDefault is set to true
$table->foreignId('comment_id')->onUpdate('cascade')->onDelete('cascade')->constrained('comments');
$table->timestamps();
});
// Create RLS policies for tables and the tenant user
pest()->artisan('tenants:rls');
// Create two tenants
$tenant1 = Tenant::create();
$tenant2 = Tenant::create();
// Create posts and comments for both tenants
tenancy()->initialize($tenant1);
$post1 = Post::create([
'text' => 'first post',
'tenant_id' => $tenant1->getTenantKey(),
'author_id' => Author::create(['name' => 'author1', 'tenant_id' => $tenant1->getTenantKey()])->id,
'category_id' => Category::create(['name' => 'category1', 'tenant_id' => $tenant1->getTenantKey()])->id,
]);
$post1Comment = Comment::create(['text' => 'first comment', 'post_id' => $post1->id]);
$post1Comment->notes()->create(['text' => 'foo']);
tenancy()->initialize($tenant2);
$post2 = Post::create([
'text' => 'second post',
'tenant_id' => $tenant2->getTenantKey(),
'author_id' => Author::create(['name' => 'author2', 'tenant_id' => $tenant2->getTenantKey()])->id,
'category_id' => Category::create(['name' => 'category2', 'tenant_id' => $tenant2->getTenantKey()])->id
]);
$post2Comment = Comment::create(['text' => 'second comment', 'post_id' => $post2->id]);
$post2Comment->notes()->create(['text' => 'bar']);
tenancy()->initialize($tenant1);
expect(Post::all()->pluck('text'))
->toHaveCount(1)
->toContain($post1->text)
->not()->toContain($post2->text)
->toEqual(Post::withoutGlobalScopes()->get()->pluck('text'));
expect(Comment::all()->pluck('text'))
->toHaveCount(1)
->toContain($post1Comment->text)
->not()->toContain($post2Comment->text)
->toEqual(Comment::withoutGlobalScopes()->get()->pluck('text'));
expect(Note::all()->pluck('text'))
->toHaveCount(1)
->toContain('foo') // $note1->text
->not()->toContain('bar') // $note2->text
->toEqual(Note::withoutGlobalScopes()->get()->pluck('text'));
tenancy()->end();
expect(Post::all()->pluck('text'))
->toHaveCount(2)
->toContain($post1->text)
->toContain($post2->text);
expect(Comment::all()->pluck('text'))
->toHaveCount(2)
->toContain($post1Comment->text)
->toContain($post2Comment->text);
expect(Note::all()->pluck('text'))
->toHaveCount(2)
->toContain('foo')
->toContain('bar');
tenancy()->initialize($tenant2);
expect(Post::all()->pluck('text'))
->toHaveCount(1)
->toContain($post2->text)
->not()->toContain($post1->text)
->toEqual(Post::withoutGlobalScopes()->get()->pluck('text'));
expect(Comment::all()->pluck('text'))
->toHaveCount(1)
->toContain($post2Comment->text)
->not()->toContain($post1Comment->text)
->toEqual(Comment::withoutGlobalScopes()->get()->pluck('text'));
expect(Note::all()->pluck('text'))
->toHaveCount(1)
->toContain('bar')
->not()->toContain('foo')
->toEqual(Note::withoutGlobalScopes()->get()->pluck('text'));
// Test that RLS policies protect tenants from other tenant's direct queries
// Try updating records of the other tenant should have no effect
DB::statement("UPDATE posts SET text = 'updated' WHERE id = {$post1->id}");
DB::statement("UPDATE comments SET text = 'updated' WHERE id = {$post1Comment->id}");
DB::statement("UPDATE notes SET text = 'updated'"); // should only update the current tenant's comments
// Still in tenant2
expect(Note::all()->pluck('text'))
->toContain('updated'); // query with no WHERE updated the current tenant's comments
expect(Post::all()->pluck('text'))
->toContain('second post'); // query with a where targeting another user's post had no effect on the current tenant's posts
expect(Comment::all()->pluck('text'))
->toContain('second comment'); // query with a where targeting another user's post had no effect on the current tenant's posts
tenancy()->initialize($tenant1);
expect(Post::all()->pluck('text'))
->toContain($post1->text)
->not()->toContain($post2->text)
->not()->toContain('updated') // Text of tenant records wasn't changed to 'updated'
->toEqual(Post::withoutGlobalScopes()->get()->pluck('text'));
expect(Comment::all()->pluck('text'))
->toContain($post1Comment->text)
->not()->toContain($post2Comment->text)
->not()->toContain('updated')
->toEqual(Comment::withoutGlobalScopes()->get()->pluck('text'));
expect(Note::all()->pluck('text'))
->toContain('foo')
->not()->toContain('bar')
->not()->toContain('updated')
->toEqual(Note::withoutGlobalScopes()->get()->pluck('text'));
// Try deleting second tenant's records should have no effect
DB::statement("DELETE FROM posts WHERE id = {$post2->id}");
DB::statement("DELETE FROM comments WHERE id = {$post2Comment->id}");
DB::statement("DELETE FROM notes");
// Still in tenant1
expect(Post::all())->toHaveCount(1); // query with a where targeting another tenant's post had no effect on the current tenant's posts
expect(Comment::all())->toHaveCount(1); // query with a where targeting another tenant's post had no effect on the current tenant's posts
expect(Note::all())->toHaveCount(0); // query with no WHERE updated the current tenant's comments
tenancy()->initialize($tenant2);
// Records weren't deleted by the first tenant
expect(Post::count())->toBe(1);
expect(Comment::count())->toBe(1);
expect(Note::count())->toBe(1);
// Directly inserting records to other tenant's tables should fail (insufficient privilege error new row violates row-level security policy)
expect(fn () => DB::statement("INSERT INTO posts (text, author_id, category_id, tenant_id) VALUES ('third post', 1, 1, '{$tenant1->getTenantKey()}')"))
->toThrow(QueryException::class);
expect(fn () => DB::statement("INSERT INTO comments (text, post_id) VALUES ('third comment', {$post1->id})"))
->toThrow(QueryException::class);
expect(fn () => DB::statement("INSERT INTO notes (text, comment_id) VALUES ('baz', {$post1Comment->id})"))
->toThrow(QueryException::class);
});
test('table rls manager generates relationship trees with tables related to the tenants table', function (bool $scopeByDefault) {
TableRLSManager::$scopeByDefault = $scopeByDefault;
/** @var TableRLSManager $manager */
$manager = app(TableRLSManager::class);
$expectedTrees = [
'authors' => [
// Directly related to tenants
'tenant_id' => [
[
[
'foreignKey' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignId' => 'id',
'nullable' => false,
]
],
],
],
'comments' => [
// Tree starting from the post_id foreign key
'post_id' => [
[
[
'foreignKey' => 'post_id',
'foreignTable' => 'posts',
'foreignId' => 'id',
'nullable' => false,
],
[
'foreignKey' => 'author_id',
'foreignTable' => 'authors',
'foreignId' => 'id',
'nullable' => false,
],
[
'foreignKey' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignId' => 'id',
'nullable' => false,
],
],
[
[
'foreignKey' => 'post_id',
'foreignTable' => 'posts',
'foreignId' => 'id',
'nullable' => false,
],
[
'foreignKey' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignId' => 'id',
'nullable' => true,
],
],
],
],
'posts' => [
// Category tree gets excluded because the category table is related to the tenant table
// only through a column with the 'no-rls' comment
'author_id' => [
[
[
'foreignKey' => 'author_id',
'foreignTable' => 'authors',
'foreignId' => 'id',
'nullable' => false,
],
[
'foreignKey' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignId' => 'id',
'nullable' => false,
]
],
],
'tenant_id' => [
[
[
'foreignKey' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignId' => 'id',
'nullable' => true,
]
]
],
],
// Articles table is ignored because it's not related to the tenant table in any way
// Reactions table is ignored because of the 'no-rls' comment on the comment_id column
// Categories table is ignored because of the 'no-rls' comment on the tenant_id column
];
expect($manager->generateTrees())->toEqual($expectedTrees);
$expectedShortestPaths = [
'authors' => [
[
'foreignKey' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignId' => 'id',
],
],
'posts' => [
[
'foreignKey' => 'author_id',
'foreignTable' => 'authors',
'foreignId' => 'id',
],
[
'foreignKey' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignId' => 'id',
],
],
'comments' => [
[
'foreignKey' => 'post_id',
'foreignTable' => 'posts',
'foreignId' => 'id',
],
[
'foreignKey' => 'author_id',
'foreignTable' => 'authors',
'foreignId' => 'id',
],
[
'foreignKey' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignId' => 'id',
],
],
];
expect($manager->shortestPaths())->toEqual($expectedShortestPaths);
// Only related to the tenants table through nullable columns tenant_id and indirectly through post_id
Schema::create('ratings', function (Blueprint $table) {
$table->id();
$table->integer('stars')->default(0);
$table->unsignedBigInteger('post_id')->nullable()->comment('rls');
$table->foreign('post_id')->references('id')->on('posts')->onUpdate('cascade')->onDelete('cascade');
// No 'rls' comment should get excluded from full trees when using explicit scoping
$table->string('tenant_id')->nullable();
$table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade');
$table->timestamps();
});
// The shortest paths should include a path for the ratings table
// That leads through tenant_id when scoping by default is enabled, that's the shortest path
// When scoping by default is disabled, the shortest path leads through post_id
// This behavior is handled by the manager's generateTrees() method, which is called by shortestPaths()
$shortestPaths = $manager->shortestPaths();
$expectedShortestPath = $scopeByDefault ? [
[
'foreignKey' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignId' => 'id',
],
] : [
[
'foreignKey' => 'post_id',
'foreignTable' => 'posts',
'foreignId' => 'id',
],
[
'foreignKey' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignId' => 'id',
],
];
expect($shortestPaths['ratings'])->toBe($expectedShortestPath);
// Add non-nullable comment_id foreign key
Schema::table('ratings', function (Blueprint $table) {
$table->foreignId('comment_id')->onUpdate('cascade')->onDelete('cascade')->comment('rls')->constrained('comments');
});
// Non-nullable paths are preferred over nullable paths
// The shortest paths should include a path for the ratings table
// That leads through comment_id instead of tenant_id
$shortestPaths = $manager->shortestPaths();
expect($shortestPaths['ratings'])->toBe([
[
'foreignKey' => 'comment_id',
'foreignTable' => 'comments',
'foreignId' => 'id',
],
[
'foreignKey' => 'post_id',
'foreignTable' => 'posts',
'foreignId' => 'id',
],
[
'foreignKey' => 'author_id',
'foreignTable' => 'authors',
'foreignId' => 'id',
],
[
'foreignKey' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignId' => 'id',
],
]);
})->with([true, false]);
test('table rls manager generates queries correctly', function() {
$sessionVariableName = config('tenancy.rls.session_variable_name');
expect(app(TableRLSManager::class)->generateQueries())->toEqualCanonicalizing([
<<<SQL
CREATE POLICY authors_rls_policy ON authors USING (
tenant_id::text = current_setting('my.current_tenant')
);
SQL,
<<<SQL
CREATE POLICY posts_rls_policy ON posts USING (
author_id IN (
SELECT id
FROM authors
WHERE tenant_id::text = current_setting('my.current_tenant')
)
);
SQL,
<<<SQL
CREATE POLICY comments_rls_policy ON comments USING (
post_id IN (
SELECT id
FROM posts
WHERE author_id IN (
SELECT id
FROM authors
WHERE tenant_id::text = current_setting('my.current_tenant')
)
)
);
SQL,
]);
// Query generation works when passing custom paths
$paths = [
'primaries' => [
[
'foreignKey' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignId' => 'id',
],
],
'secondaries' => [
[
'foreignKey' => 'primary_id',
'foreignTable' => 'primaries',
'foreignId' => 'id',
],
[
'foreignKey' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignId' => 'id',
],
],
'foo' => [
[
'foreignKey' => 'secondary_id',
'foreignTable' => 'secondaries',
'foreignId' => 'id',
],
[
'foreignKey' => 'primary_id',
'foreignTable' => 'primaries',
'foreignId' => 'id',
],
[
'foreignKey' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignId' => 'id',
],
],
];
expect(app(TableRLSManager::class)->generateQueries($paths))->toContain(
<<<SQL
CREATE POLICY primaries_rls_policy ON primaries USING (
tenant_id::text = current_setting('my.current_tenant')
);
SQL,
<<<SQL
CREATE POLICY secondaries_rls_policy ON secondaries USING (
primary_id IN (
SELECT id
FROM primaries
WHERE tenant_id::text = current_setting('my.current_tenant')
)
);
SQL,
<<<SQL
CREATE POLICY foo_rls_policy ON foo USING (
secondary_id IN (
SELECT id
FROM secondaries
WHERE primary_id IN (
SELECT id
FROM primaries
WHERE tenant_id::text = current_setting('my.current_tenant')
)
)
);
SQL,
);
});
test('table manager throws an exception when encountering a recursive relationship', function() {
Schema::create('recursive_posts', function (Blueprint $table) {
$table->id();
$table->foreignId('highlighted_comment_id')->constrained('comments')->nullable()->comment('rls');
});
Schema::table('comments', function (Blueprint $table) {
$table->foreignId('recursive_post_id')->constrained('recursive_posts')->comment('rls');
});
expect(fn () => app(TableRLSManager::class)->generateTrees())->toThrow(RecursiveRelationshipException::class);
});
class Post extends Model
{
protected $guarded = [];
public $timestamps = false;
public function comments(): HasMany
{
return $this->hasMany(Comment::class, 'post_id');
}
}
class Comment extends Model
{
protected $guarded = [];
protected $table = 'comments';
public $timestamps = false;
public function post(): BelongsTo
{
return $this->belongsTo(Post::class);
}
public function notes(): HasMany
{
return $this->hasMany(Note::class);
}
}
class Note extends Model
{
protected $guarded = [];
public $timestamps = false;
public function comment(): BelongsTo
{
return $this->belongsTo(Comment::class);
}
}
class Category extends Model
{
protected $guarded = [];
}
class Author extends Model
{
protected $guarded = [];
}

View file

@ -0,0 +1,328 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\DB;
use Stancl\Tenancy\Tests\Etc\Tenant;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Schema;
use Stancl\Tenancy\Tests\RLS\Etc\Post;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\QueryException;
use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Database\TenantScope;
use Illuminate\Database\Schema\Blueprint;
use Stancl\Tenancy\Tests\RLS\Etc\Article;
use Stancl\Tenancy\Tests\RLS\Etc\Comment;
use Stancl\Tenancy\Database\ParentModelScope;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Stancl\Tenancy\Database\Concerns\BelongsToTenant;
use Stancl\Tenancy\Commands\CreateUserWithRLSPolicies;
use Stancl\Tenancy\RLS\PolicyManagers\TraitRLSManager;
use Stancl\Tenancy\Bootstrappers\PostgresRLSBootstrapper;
use Stancl\Tenancy\Database\Concerns\BelongsToPrimaryModel;
beforeEach(function () {
TraitRLSManager::$implicitRLS = true;
TraitRLSManager::$modelDirectories = [__DIR__ . '/Etc'];
TraitRLSManager::$excludedModels = [Article::class];
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
DB::purge($centralConnection = config('tenancy.database.central_connection'));
config(['database.connections.' . $centralConnection => config('database.connections.pgsql')]);
config(['tenancy.models.tenant_key_column' => 'tenant_id']);
config(['tenancy.models.tenant' => Tenant::class]);
config(['tenancy.rls.manager' => TraitRLSManager::class]);
config(['tenancy.rls.user' => [
'username' => 'username',
'password' => 'password',
]]);
config(['tenancy.bootstrappers' => [PostgresRLSBootstrapper::class]]);
pest()->artisan('migrate:fresh', [
'--force' => true,
'--path' => __DIR__ . '/../../assets/migrations',
'--realpath' => true,
]);
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->string('text');
$table->string('tenant_id');
$table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade');
$table->timestamps();
});
Schema::create('comments', function (Blueprint $table) {
$table->id();
$table->string('text');
$table->foreignId('post_id')->constrained('posts')->onUpdate('cascade')->onDelete('cascade');
$table->timestamps();
});
// Exists to check that the manager doesn't generate queries for models excluded from model discovery
Schema::create('articles', function (Blueprint $table) {
$table->id();
$table->string('text');
$table->timestamps();
});
});
test('correct rls policies get created with the correct hash using trait manager', function () {
$manager = app(TraitRLSManager::class);
// Tables that are directly or indirectly related to the tenant
$tables = collect($manager->getModels())
->filter(fn (Model $model) => $manager->modelBelongsToTenant($model) || $manager->modelBelongsToTenantIndirectly($model))
->map(fn (Model $model) => $model->getTable())
->values()
->unique()
->toArray();
$getRLSPolicies = fn () => DB::select('SELECT policyname, tablename FROM pg_policies');
$getRLSTables = fn () => collect($tables)->map(fn ($table) => DB::select('SELECT relname, relforcerowsecurity FROM pg_class WHERE oid = ?::regclass', [$table]))->collapse();
expect($getRLSPolicies())->toHaveCount(0);
pest()->artisan('tenants:rls');
// Check if all tables related to the tenant have RLS policies
expect($policies = $getRLSPolicies())->toHaveCount(count($tables));
expect($rlsTables = $getRLSTables())->toHaveCount(count($tables));
foreach ($rlsTables as $table) {
expect($tables)->toContain($table->relname);
expect($table->relforcerowsecurity)->toBeTrue();
}
// Check that the policies get suffixed with the correct hash
$queries = $manager->generateQueries();
expect(array_keys($queries))->toEqualCanonicalizing($tables);
expect(array_keys($queries))->not()->toContain('articles');
/** @var CreateUserWithRLSPolicies $policyCreationCommand */
$policyCreationCommand = app(CreateUserWithRLSPolicies::class);
foreach ($queries as $table => $query) {
$policy = collect($policies)->filter(fn (object $policy) => $policy->tablename === $table)->first();
$hash = $policyCreationCommand->hashPolicy($query)[0];
$policyNameWithHash = "{$table}_rls_policy_{$hash}";
expect($tables)->toContain($policy->tablename);
expect($policy->policyname)->toBe($policyNameWithHash);
}
});
test('global scope is not applied when using rls with single db traits', function () {
// The global scopes (TenantScope and ParentModelScope) are added to models
// that are using the single DB traits (BelongsToTenant and BelongsToPrimaryModel)
// if TraitRLSManager::$implicitRLS is false and the model does not implement RLSModel
TraitRLSManager::$implicitRLS = false;
// Post model uses BelongsToTenant
// Comment uses BelongsToPrimaryModel
// Both models implement RLSModel, so they shouldn't have the global scope
expect(Post::make()->hasGlobalScope(TenantScope::class))->toBeFalse();
expect(Comment::make()->hasGlobalScope(ParentModelScope::class))->toBeFalse();
// These models DO NOT implement RLSModel
expect(NonRLSPost::make()->hasGlobalScope(TenantScope::class))->toBeTrue();
expect(NonRLSComment::make()->hasGlobalScope(ParentModelScope::class))->toBeTrue();
TraitRLSManager::$implicitRLS = true;
NonRLSPost::clearBootedModels();
NonRLSComment::clearBootedModels();
// Both NonRLSPost and NonRLSComment use the single DB traits, but don't implement RLSModel
// The models still shouldn't have the global scope because RLS is enabled implicitly
expect(NonRLSPost::make()->hasGlobalScope(TenantScope::class))->toBeFalse();
expect(NonRLSComment::make()->hasGlobalScope(ParentModelScope::class))->toBeFalse();
});
test('queries are correctly scoped using RLS with trait rls manager', function (bool $implicitRLS) {
TraitRLSManager::$implicitRLS = $implicitRLS;
$postModel = $implicitRLS ? NonRLSPost::class : Post::class;
$commentModel = $implicitRLS ? NonRLSComment::class : Comment::class;
// Create RLS policies for tables and the tenant user
pest()->artisan('tenants:rls');
// Create two tenants
$tenant1 = Tenant::create();
$tenant2 = Tenant::create();
// Create posts and comments for both tenants
tenancy()->initialize($tenant1);
$post1 = $postModel::create([
'text' => 'first post',
]);
$post1Comment = $commentModel::create(['text' => 'first comment', 'post_id' => $post1->id]);
tenancy()->initialize($tenant2);
$post2 = $postModel::create([
'text' => 'second post',
]);
$post2Comment = $commentModel::create(['text' => 'second comment', 'post_id' => $post2->id]);
tenancy()->initialize($tenant1);
expect($postModel::all()->pluck('text'))
->toHaveCount(1)
->toContain($post1->text)
->not()->toContain($post2->text)
->toEqual($postModel::withoutGlobalScopes()->get()->pluck('text'));
expect($commentModel::all()->pluck('text'))
->toHaveCount(1)
->toContain($post1Comment->text)
->not()->toContain($post2Comment->text)
->toEqual($commentModel::withoutGlobalScopes()->get()->pluck('text'));
tenancy()->end();
expect($postModel::all()->pluck('text'))
->toHaveCount(2)
->toContain($post1->text)
->toContain($post2->text);
expect($commentModel::all()->pluck('text'))
->toHaveCount(2)
->toContain($post1Comment->text)
->toContain($post2Comment->text);
tenancy()->initialize($tenant2);
expect($postModel::all()->pluck('text'))
->toHaveCount(1)
->toContain($post2->text)
->not()->toContain($post1->text)
->toEqual($postModel::withoutGlobalScopes()->get()->pluck('text'));
expect($commentModel::all()->pluck('text'))
->toHaveCount(1)
->toContain($post2Comment->text)
->not()->toContain($post1Comment->text)
->toEqual($commentModel::withoutGlobalScopes()->get()->pluck('text'));
// Test that RLS policies protect tenants from other tenant's direct queries
DB::statement("UPDATE posts SET text = 'updated' WHERE id = {$post1->id}"); // should have no effect
DB::statement("UPDATE comments SET text = 'updated'"); // should only update the current tenant's comments
// Still in tenant2
expect($commentModel::all()->pluck('text'))
->toContain('updated'); // query with no WHERE updated the current tenant's comments
expect($postModel::all()->pluck('text'))
->toContain('second post'); // query with a where targeting another tenant's post had no effect on the current tenant's posts
tenancy()->initialize($tenant1);
expect($postModel::all()->pluck('text'))
->toContain($post1->text)
->not()->toContain($post2->text)
->not()->toContain('updated') // Text of tenant records was NOT changed to 'updated'
->toEqual($postModel::withoutGlobalScopes()->get()->pluck('text'));
expect($commentModel::all()->pluck('text'))
->toContain($post1Comment->text)
->not()->toContain($post2Comment->text)
->not()->toContain('updated') // No change to posts either
->toEqual($commentModel::withoutGlobalScopes()->get()->pluck('text'));
// Try deleting second tenant's records should have no effect
DB::statement("DELETE FROM posts WHERE id = {$post2->id}");
DB::statement("DELETE FROM comments");
// Still in tenant1
expect($postModel::all())->toHaveCount(1); // query with a where targeting another tenant's post had no effect on the current tenant's posts
expect($commentModel::all())->toHaveCount(0); // query with no WHERE updated the current tenant's comments
tenancy()->initialize($tenant2);
// Records weren't deleted by the first tenant
expect($postModel::count())->toBe(1);
expect($commentModel::count())->toBe(1);
// Directly inserting records to other tenant's tables should fail (insufficient privilege error new row violates row-level security policy)
expect(fn () => DB::statement("INSERT INTO posts (text, tenant_id) VALUES ('third post', '{$tenant1->getTenantKey()}')"))
->toThrow(QueryException::class);
expect(fn () => DB::statement("INSERT INTO comments (text, post_id) VALUES ('third comment', {$post1->id})"))
->toThrow(QueryException::class);
})->with([
true,
false
]);
test('trait rls manager generates queries correctly', function() {
/** @var TraitRLSManager $manager */
$manager = app(TraitRLSManager::class);
// Three tables related to tenants  posts (directly), comments (indirectly)
expect($manager->generateQueries())->toContain(
<<<SQL
CREATE POLICY posts_rls_policy ON posts USING (
tenant_id::text = current_setting('my.current_tenant')
);
SQL,
<<<SQL
CREATE POLICY comments_rls_policy ON comments USING (
post_id IN (
SELECT id
FROM posts
WHERE tenant_id::text = current_setting('my.current_tenant')
)
);
SQL,
);
});
class NonRLSPost extends Model
{
use BelongsToTenant;
public $table = 'posts';
protected $guarded = [];
public $timestamps = false;
public function comments(): HasMany
{
return $this->hasMany(NonRLSComment::class, 'post_id');
}
}
class NonRLSComment extends Model
{
use BelongsToPrimaryModel;
public $table = 'comments';
protected $guarded = [];
public $timestamps = false;
public function getRelationshipToPrimaryModel(): string
{
return 'post';
}
public function post(): BelongsTo
{
return $this->belongsTo(NonRLSPost::class, 'post_id');
}
}

View file

@ -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

View file

@ -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]
]);

View file

@ -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);

View file

@ -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;