mirror of
https://github.com/archtechx/tenancy.git
synced 2025-12-15 09:34: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:
parent
34297d3e1a
commit
7317d2638a
39 changed files with 2511 additions and 112 deletions
15
src/RLS/PolicyManagers/RLSPolicyManager.php
Normal file
15
src/RLS/PolicyManagers/RLSPolicyManager.php
Normal 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;
|
||||
}
|
||||
260
src/RLS/PolicyManagers/TableRLSManager.php
Normal file
260
src/RLS/PolicyManagers/TableRLSManager.php
Normal 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;
|
||||
}
|
||||
}
|
||||
134
src/RLS/PolicyManagers/TraitRLSManager.php
Normal file
134
src/RLS/PolicyManagers/TraitRLSManager.php
Normal 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));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue