mirror of
https://github.com/archtechx/tenancy.git
synced 2025-12-12 18:44:03 +00:00
Postgres RLS + permission controlled database managers (#33)
This PR adds Postgres RLS (trait manager + table manager approach) and permission controlled managers for PostgreSQL. --------- Co-authored-by: lukinovec <lukinovec@gmail.com> Co-authored-by: PHP CS Fixer <phpcsfixer@example.com>
This commit is contained in:
parent
34297d3e1a
commit
7317d2638a
39 changed files with 2511 additions and 112 deletions
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||
namespace Stancl\Tenancy\Database\Concerns;
|
||||
|
||||
use Stancl\Tenancy\Database\ParentModelScope;
|
||||
use Stancl\Tenancy\RLS\PolicyManagers\TraitRLSManager;
|
||||
|
||||
trait BelongsToPrimaryModel
|
||||
{
|
||||
|
|
@ -12,6 +13,10 @@ trait BelongsToPrimaryModel
|
|||
|
||||
public static function bootBelongsToPrimaryModel(): void
|
||||
{
|
||||
static::addGlobalScope(new ParentModelScope);
|
||||
$implicitRLS = config('tenancy.rls.manager') === TraitRLSManager::class && TraitRLSManager::$implicitRLS;
|
||||
|
||||
if (! $implicitRLS && ! (new static) instanceof RLSModel) {
|
||||
static::addGlobalScope(new ParentModelScope);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
22
src/Database/Concerns/FillsCurrentTenant.php
Normal file
22
src/Database/Concerns/FillsCurrentTenant.php
Normal 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());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
83
src/Database/Concerns/ManagesPostgresUsers.php
Normal file
83
src/Database/Concerns/ManagesPostgresUsers.php
Normal 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}'");
|
||||
}
|
||||
}
|
||||
21
src/Database/Concerns/RLSModel.php
Normal file
21
src/Database/Concerns/RLSModel.php
Normal 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 {}
|
||||
18
src/Database/Exceptions/RecursiveRelationshipException.php
Normal file
18
src/Database/Exceptions/RecursiveRelationshipException.php
Normal 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.");
|
||||
}
|
||||
}
|
||||
18
src/Database/Exceptions/TableNotRelatedToTenantException.php
Normal file
18
src/Database/Exceptions/TableNotRelatedToTenantException.php
Normal 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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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'");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue