Fixed collapsed perm. gen for book sub-items.

Also converted the existing "JointPermission" usage to the new
collapsed permission system.
This commit is contained in:
Dan Brown 2022-12-23 13:56:22 +00:00
parent 7330139555
commit 451e4ac452
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
19 changed files with 115 additions and 139 deletions

View File

@ -0,0 +1,18 @@
<?php
namespace BookStack\Auth\Permissions;
use BookStack\Model;
/**
* @property int $id
* @property ?int $role_id
* @property ?int $user_id
* @property string $entity_type
* @property int $entity_id
* @property bool $view
*/
class CollapsedPermission extends Model
{
protected $table = 'entity_permissions_collapsed';
}

View File

@ -13,13 +13,13 @@ use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
/** /**
* Joint permissions provide a pre-query "cached" table of view permissions for all core entity * Collapsed permissions act as a "flattened" view of entity-level permissions in the system
* types for all roles in the system. This class generates out that table for different scenarios. * so inheritance does not have to managed as part of permission querying.
*/ */
class JointPermissionBuilder class CollapsedPermissionBuilder
{ {
/** /**
* Re-generate all entity permission from scratch. * Re-generate all collapsed permissions from scratch.
*/ */
public function rebuildForAll() public function rebuildForAll()
{ {
@ -27,26 +27,26 @@ class JointPermissionBuilder
// Chunk through all books // Chunk through all books
$this->bookFetchQuery()->chunk(5, function (EloquentCollection $books) { $this->bookFetchQuery()->chunk(5, function (EloquentCollection $books) {
$this->buildJointPermissionsForBooks($books); $this->buildForBooks($books, false);
}); });
// Chunk through all bookshelves // Chunk through all bookshelves
Bookshelf::query()->withTrashed() Bookshelf::query()->withTrashed()
->select(['id', 'owned_by']) ->select(['id'])
->chunk(50, function (EloquentCollection $shelves) { ->chunk(50, function (EloquentCollection $shelves) {
$this->generateCollapsedPermissions($shelves->all()); $this->generateCollapsedPermissions($shelves->all());
}); });
} }
/** /**
* Rebuild the entity jointPermissions for a particular entity. * Rebuild the collapsed permissions for a particular entity.
*/ */
public function rebuildForEntity(Entity $entity) public function rebuildForEntity(Entity $entity)
{ {
$entities = [$entity]; $entities = [$entity];
if ($entity instanceof Book) { if ($entity instanceof Book) {
$books = $this->bookFetchQuery()->where('id', '=', $entity->id)->get(); $books = $this->bookFetchQuery()->where('id', '=', $entity->id)->get();
$this->buildJointPermissionsForBooks($books, true); $this->buildForBooks($books, true);
return; return;
} }
@ -66,7 +66,7 @@ class JointPermissionBuilder
} }
} }
$this->buildJointPermissionsForEntities($entities); $this->buildForEntities($entities);
} }
/** /**
@ -75,20 +75,20 @@ class JointPermissionBuilder
protected function bookFetchQuery(): Builder protected function bookFetchQuery(): Builder
{ {
return Book::query()->withTrashed() return Book::query()->withTrashed()
->select(['id', 'owned_by'])->with([ ->select(['id'])->with([
'chapters' => function ($query) { 'chapters' => function ($query) {
$query->withTrashed()->select(['id', 'owned_by', 'book_id']); $query->withTrashed()->select(['id', 'book_id']);
}, },
'pages' => function ($query) { 'pages' => function ($query) {
$query->withTrashed()->select(['id', 'owned_by', 'book_id', 'chapter_id']); $query->withTrashed()->select(['id', 'book_id', 'chapter_id']);
}, },
]); ]);
} }
/** /**
* Build joint permissions for the given book and role combinations. * Build collapsed permissions for the given books.
*/ */
protected function buildJointPermissionsForBooks(EloquentCollection $books, bool $deleteOld = false) protected function buildForBooks(EloquentCollection $books, bool $deleteOld)
{ {
$entities = clone $books; $entities = clone $books;
@ -103,27 +103,27 @@ class JointPermissionBuilder
} }
if ($deleteOld) { if ($deleteOld) {
$this->deleteManyJointPermissionsForEntities($entities->all()); $this->deleteForEntities($entities->all());
} }
$this->generateCollapsedPermissions($entities->all()); $this->generateCollapsedPermissions($entities->all());
} }
/** /**
* Rebuild the entity jointPermissions for a collection of entities. * Rebuild the collapsed permissions for a collection of entities.
*/ */
protected function buildJointPermissionsForEntities(array $entities) protected function buildForEntities(array $entities)
{ {
$this->deleteManyJointPermissionsForEntities($entities); $this->deleteForEntities($entities);
$this->generateCollapsedPermissions($entities); $this->generateCollapsedPermissions($entities);
} }
/** /**
* Delete all the entity jointPermissions for a list of entities. * Delete the stored collapsed permissions for a list of entities.
* *
* @param Entity[] $entities * @param Entity[] $entities
*/ */
protected function deleteManyJointPermissionsForEntities(array $entities) protected function deleteForEntities(array $entities)
{ {
$simpleEntities = $this->entitiesToSimpleEntities($entities); $simpleEntities = $this->entitiesToSimpleEntities($entities);
$idsByType = $this->entitiesToTypeIdMap($simpleEntities); $idsByType = $this->entitiesToTypeIdMap($simpleEntities);
@ -141,6 +141,9 @@ class JointPermissionBuilder
} }
/** /**
* Convert the given list of entities into "SimpleEntityData" representations
* for faster usage and property access.
*
* @param Entity[] $entities * @param Entity[] $entities
* *
* @return SimpleEntityData[] * @return SimpleEntityData[]
@ -154,7 +157,6 @@ class JointPermissionBuilder
$simple = new SimpleEntityData(); $simple = new SimpleEntityData();
$simple->id = $attrs['id']; $simple->id = $attrs['id'];
$simple->type = $entity->getMorphClass(); $simple->type = $entity->getMorphClass();
$simple->owned_by = $attrs['owned_by'] ?? 0;
$simple->book_id = $attrs['book_id'] ?? null; $simple->book_id = $attrs['book_id'] ?? null;
$simple->chapter_id = $attrs['chapter_id'] ?? null; $simple->chapter_id = $attrs['chapter_id'] ?? null;
$simpleEntities[] = $simple; $simpleEntities[] = $simple;
@ -171,7 +173,7 @@ class JointPermissionBuilder
protected function generateCollapsedPermissions(array $originalEntities) protected function generateCollapsedPermissions(array $originalEntities)
{ {
$entities = $this->entitiesToSimpleEntities($originalEntities); $entities = $this->entitiesToSimpleEntities($originalEntities);
$jointPermissions = []; $collapsedPermData = [];
// Fetch related entity permissions // Fetch related entity permissions
$permissions = $this->getEntityPermissionsForEntities($entities); $permissions = $this->getEntityPermissionsForEntities($entities);
@ -181,12 +183,12 @@ class JointPermissionBuilder
// Create Joint Permission Data // Create Joint Permission Data
foreach ($entities as $entity) { foreach ($entities as $entity) {
array_push($jointPermissions, ...$this->createCollapsedPermissionData($entity, $permissionMap)); array_push($collapsedPermData, ...$this->createCollapsedPermissionData($entity, $permissionMap));
} }
DB::transaction(function () use ($jointPermissions) { DB::transaction(function () use ($collapsedPermData) {
foreach (array_chunk($jointPermissions, 1000) as $jointPermissionChunk) { foreach (array_chunk($collapsedPermData, 1000) as $dataChunk) {
DB::table('entity_permissions_collapsed')->insert($jointPermissionChunk); DB::table('entity_permissions_collapsed')->insert($dataChunk);
} }
}); });
} }
@ -198,8 +200,8 @@ class JointPermissionBuilder
{ {
$chain = [ $chain = [
$entity->type . ':' . $entity->id, $entity->type . ':' . $entity->id,
$entity->chapter_id ? null : ('chapter:' . $entity->chapter_id), $entity->chapter_id ? ('chapter:' . $entity->chapter_id) : null,
$entity->book_id ? null : ('book:' . $entity->book_id), $entity->book_id ? ('book:' . $entity->book_id) : null,
]; ];
$permissionData = []; $permissionData = [];

View File

@ -1,31 +0,0 @@
<?php
namespace BookStack\Auth\Permissions;
use BookStack\Auth\Role;
use BookStack\Entities\Models\Entity;
use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphOne;
class JointPermission extends Model
{
protected $primaryKey = null;
public $timestamps = false;
/**
* Get the role that this points to.
*/
public function role(): BelongsTo
{
return $this->belongsTo(Role::class);
}
/**
* Get the entity this points to.
*/
public function entity(): MorphOne
{
return $this->morphOne(Entity::class, 'entity');
}
}

View File

@ -1,31 +0,0 @@
<?php
namespace BookStack\Auth\Permissions;
use BookStack\Entities\Models\Entity;
use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\MorphOne;
/**
* Holds the "cached" user-specific permissions for entities in the system.
* These only exist to indicate resolved permissions active via user-specific
* entity permissions, not for all permission combinations for all users.
*
* @property int $user_id
* @property int $entity_id
* @property string $entity_type
* @property boolean $has_permission
*/
class JointUserPermission extends Model
{
protected $primaryKey = null;
public $timestamps = false;
/**
* Get the entity this points to.
*/
public function entity(): MorphOne
{
return $this->morphOne(Entity::class, 'entity');
}
}

View File

@ -11,13 +11,13 @@ use Illuminate\Database\Eloquent\Collection;
class PermissionsRepo class PermissionsRepo
{ {
protected JointPermissionBuilder $permissionBuilder; protected CollapsedPermissionBuilder $permissionBuilder;
protected $systemRoles = ['admin', 'public']; protected array $systemRoles = ['admin', 'public'];
/** /**
* PermissionsRepo constructor. * PermissionsRepo constructor.
*/ */
public function __construct(JointPermissionBuilder $permissionBuilder) public function __construct(CollapsedPermissionBuilder $permissionBuilder)
{ {
$this->permissionBuilder = $permissionBuilder; $this->permissionBuilder = $permissionBuilder;
} }
@ -138,7 +138,7 @@ class PermissionsRepo
} }
$role->entityPermissions()->delete(); $role->entityPermissions()->delete();
$role->jointPermissions()->delete(); $role->collapsedPermissions()->delete();
Activity::add(ActivityType::ROLE_DELETE, $role); Activity::add(ActivityType::ROLE_DELETE, $role);
$role->delete(); $role->delete();
} }

View File

@ -6,7 +6,6 @@ class SimpleEntityData
{ {
public int $id; public int $id;
public string $type; public string $type;
public int $owned_by;
public ?int $book_id; public ?int $book_id;
public ?int $chapter_id; public ?int $chapter_id;
} }

View File

@ -2,8 +2,8 @@
namespace BookStack\Auth; namespace BookStack\Auth;
use BookStack\Auth\Permissions\CollapsedPermission;
use BookStack\Auth\Permissions\EntityPermission; use BookStack\Auth\Permissions\EntityPermission;
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Auth\Permissions\RolePermission; use BookStack\Auth\Permissions\RolePermission;
use BookStack\Interfaces\Loggable; use BookStack\Interfaces\Loggable;
use BookStack\Model; use BookStack\Model;
@ -39,14 +39,6 @@ class Role extends Model implements Loggable
return $this->belongsToMany(User::class)->orderBy('name', 'asc'); return $this->belongsToMany(User::class)->orderBy('name', 'asc');
} }
/**
* Get all related JointPermissions.
*/
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class);
}
/** /**
* The RolePermissions that belong to the role. * The RolePermissions that belong to the role.
*/ */
@ -63,6 +55,14 @@ class Role extends Model implements Loggable
return $this->hasMany(EntityPermission::class); return $this->hasMany(EntityPermission::class);
} }
/**
* Get all related entity collapsed permissions.
*/
public function collapsedPermissions(): HasMany
{
return $this->hasMany(CollapsedPermission::class);
}
/** /**
* Check if this role has a permission. * Check if this role has a permission.
*/ */

View File

@ -5,6 +5,8 @@ namespace BookStack\Auth;
use BookStack\Actions\Favourite; use BookStack\Actions\Favourite;
use BookStack\Api\ApiToken; use BookStack\Api\ApiToken;
use BookStack\Auth\Access\Mfa\MfaValue; use BookStack\Auth\Access\Mfa\MfaValue;
use BookStack\Auth\Permissions\CollapsedPermission;
use BookStack\Auth\Permissions\EntityPermission;
use BookStack\Entities\Tools\SlugGenerator; use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Interfaces\Loggable; use BookStack\Interfaces\Loggable;
use BookStack\Interfaces\Sluggable; use BookStack\Interfaces\Sluggable;
@ -298,6 +300,22 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
}, 'activities', 'users.id', '=', 'activities.user_id'); }, 'activities', 'users.id', '=', 'activities.user_id');
} }
/**
* Get the entity permissions assigned to this specific user.
*/
public function entityPermissions(): HasMany
{
return $this->hasMany(EntityPermission::class);
}
/**
* Get all related entity collapsed permissions.
*/
public function collapsedPermissions(): HasMany
{
return $this->hasMany(CollapsedPermission::class);
}
/** /**
* Get the url for editing this user. * Get the url for editing this user.
*/ */

View File

@ -153,6 +153,8 @@ class UserRepo
$user->apiTokens()->delete(); $user->apiTokens()->delete();
$user->favourites()->delete(); $user->favourites()->delete();
$user->mfaValues()->delete(); $user->mfaValues()->delete();
$user->collapsedPermissions()->delete();
$user->entityPermissions()->delete();
$user->delete(); $user->delete();
// Delete user profile images // Delete user profile images

View File

@ -2,7 +2,7 @@
namespace BookStack\Console\Commands; namespace BookStack\Console\Commands;
use BookStack\Auth\Permissions\JointPermissionBuilder; use BookStack\Auth\Permissions\CollapsedPermissionBuilder;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -22,12 +22,12 @@ class RegeneratePermissions extends Command
*/ */
protected $description = 'Regenerate all system permissions'; protected $description = 'Regenerate all system permissions';
protected JointPermissionBuilder $permissionBuilder; protected CollapsedPermissionBuilder $permissionBuilder;
/** /**
* Create a new command instance. * Create a new command instance.
*/ */
public function __construct(JointPermissionBuilder $permissionBuilder) public function __construct(CollapsedPermissionBuilder $permissionBuilder)
{ {
$this->permissionBuilder = $permissionBuilder; $this->permissionBuilder = $permissionBuilder;
parent::__construct(); parent::__construct();

View File

@ -7,10 +7,9 @@ use BookStack\Actions\Comment;
use BookStack\Actions\Favourite; use BookStack\Actions\Favourite;
use BookStack\Actions\Tag; use BookStack\Actions\Tag;
use BookStack\Actions\View; use BookStack\Actions\View;
use BookStack\Auth\Permissions\CollapsedPermission;
use BookStack\Auth\Permissions\EntityPermission; use BookStack\Auth\Permissions\EntityPermission;
use BookStack\Auth\Permissions\JointPermission; use BookStack\Auth\Permissions\CollapsedPermissionBuilder;
use BookStack\Auth\Permissions\JointPermissionBuilder;
use BookStack\Auth\Permissions\JointUserPermission;
use BookStack\Auth\Permissions\PermissionApplicator; use BookStack\Auth\Permissions\PermissionApplicator;
use BookStack\Entities\Tools\SlugGenerator; use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Interfaces\Deletable; use BookStack\Interfaces\Deletable;
@ -188,19 +187,11 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
} }
/** /**
* Get the entity jointPermissions this is connected to. * Get the entity collapsed permissions this is connected to.
*/ */
public function jointPermissions(): MorphMany public function collapsedPermissions(): MorphMany
{ {
return $this->morphMany(JointPermission::class, 'entity'); return $this->morphMany(CollapsedPermission::class, 'entity');
}
/**
* Get the join user permissions for this entity.
*/
public function jointUserPermissions(): MorphMany
{
return $this->morphMany(JointUserPermission::class, 'entity');
} }
/** /**
@ -301,7 +292,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
*/ */
public function rebuildPermissions() public function rebuildPermissions()
{ {
app()->make(JointPermissionBuilder::class)->rebuildForEntity(clone $this); app()->make(CollapsedPermissionBuilder::class)->rebuildForEntity(clone $this);
} }
/** /**

View File

@ -372,7 +372,7 @@ class TrashCan
$entity->permissions()->delete(); $entity->permissions()->delete();
$entity->tags()->delete(); $entity->tags()->delete();
$entity->comments()->delete(); $entity->comments()->delete();
$entity->jointPermissions()->delete(); $entity->collapsedPermissions()->delete();
$entity->searchTerms()->delete(); $entity->searchTerms()->delete();
$entity->deletions()->delete(); $entity->deletions()->delete();
$entity->favourites()->delete(); $entity->favourites()->delete();

View File

@ -55,8 +55,9 @@ function hasAppAccess(): bool
} }
/** /**
* Check if the current user has a permission. If an ownable element * Check if the current user has a permission.
* is passed in the jointPermissions are checked against that particular item. * Checks a generic role permission or, if an ownable model is passed in, it will
* check against the given entity model, taking into account entity-level permissions.
*/ */
function userCan(string $permission, Model $ownable = null): bool function userCan(string $permission, Model $ownable = null): bool
{ {

View File

@ -13,6 +13,9 @@ class CreateCollapsedRolePermissionsTable extends Migration
*/ */
public function up() public function up()
{ {
// TODO - Drop joint permissions
// TODO - Run collapsed table rebuild.
Schema::create('entity_permissions_collapsed', function (Blueprint $table) { Schema::create('entity_permissions_collapsed', function (Blueprint $table) {
$table->id(); $table->id();
$table->unsignedInteger('role_id')->nullable(); $table->unsignedInteger('role_id')->nullable();

View File

@ -3,7 +3,7 @@
namespace Database\Seeders; namespace Database\Seeders;
use BookStack\Api\ApiToken; use BookStack\Api\ApiToken;
use BookStack\Auth\Permissions\JointPermissionBuilder; use BookStack\Auth\Permissions\CollapsedPermissionBuilder;
use BookStack\Auth\Permissions\RolePermission; use BookStack\Auth\Permissions\RolePermission;
use BookStack\Auth\Role; use BookStack\Auth\Role;
use BookStack\Auth\User; use BookStack\Auth\User;
@ -69,7 +69,7 @@ class DummyContentSeeder extends Seeder
]); ]);
$token->save(); $token->save();
app(JointPermissionBuilder::class)->rebuildForAll(); app(CollapsedPermissionBuilder::class)->rebuildForAll();
app(SearchIndex::class)->indexAllEntities(); app(SearchIndex::class)->indexAllEntities();
} }
} }

View File

@ -2,7 +2,7 @@
namespace Database\Seeders; namespace Database\Seeders;
use BookStack\Auth\Permissions\JointPermissionBuilder; use BookStack\Auth\Permissions\CollapsedPermissionBuilder;
use BookStack\Auth\Role; use BookStack\Auth\Role;
use BookStack\Auth\User; use BookStack\Auth\User;
use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Book;
@ -35,7 +35,7 @@ class LargeContentSeeder extends Seeder
$largeBook->chapters()->saveMany($chapters); $largeBook->chapters()->saveMany($chapters);
$all = array_merge([$largeBook], array_values($pages->all()), array_values($chapters->all())); $all = array_merge([$largeBook], array_values($pages->all()), array_values($chapters->all()));
app()->make(JointPermissionBuilder::class)->rebuildForEntity($largeBook); app()->make(CollapsedPermissionBuilder::class)->rebuildForEntity($largeBook);
app()->make(SearchIndex::class)->indexEntities($all); app()->make(SearchIndex::class)->indexEntities($all);
} }
} }

View File

@ -2,8 +2,7 @@
namespace Tests\Commands; namespace Tests\Commands;
use BookStack\Auth\Permissions\JointPermission; use BookStack\Auth\Permissions\CollapsedPermission;
use BookStack\Entities\Models\Page;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Tests\TestCase; use Tests\TestCase;
@ -13,15 +12,23 @@ class RegeneratePermissionsCommandTest extends TestCase
public function test_regen_permissions_command() public function test_regen_permissions_command()
{ {
DB::rollBack(); DB::rollBack();
JointPermission::query()->truncate(); $page = $this->entities->page();
$page = Page::first(); $editor = $this->users->editor();
$this->permissions->addEntityPermission($page, ['view'], null, $editor);
CollapsedPermission::query()->truncate();
$this->assertDatabaseMissing('joint_permissions', ['entity_id' => $page->id]); $this->assertDatabaseMissing('entity_permissions_collapsed', ['entity_id' => $page->id]);
$exitCode = Artisan::call('bookstack:regenerate-permissions'); $exitCode = Artisan::call('bookstack:regenerate-permissions');
$this->assertTrue($exitCode === 0, 'Command executed successfully'); $this->assertTrue($exitCode === 0, 'Command executed successfully');
DB::beginTransaction();
$this->assertDatabaseHas('joint_permissions', ['entity_id' => $page->id]); $this->assertDatabaseHas('entity_permissions_collapsed', [
'entity_id' => $page->id,
'user_id' => $editor->id,
'view' => 1,
]);
CollapsedPermission::query()->truncate();
DB::beginTransaction();
} }
} }

View File

@ -3,7 +3,6 @@
namespace Tests\Helpers; namespace Tests\Helpers;
use BookStack\Auth\Permissions\EntityPermission; use BookStack\Auth\Permissions\EntityPermission;
use BookStack\Auth\Permissions\JointPermissionBuilder;
use BookStack\Auth\Permissions\RolePermission; use BookStack\Auth\Permissions\RolePermission;
use BookStack\Auth\Role; use BookStack\Auth\Role;
use BookStack\Auth\User; use BookStack\Auth\User;
@ -70,7 +69,6 @@ class PermissionsProvider
public function regenerateForEntity(Entity $entity): void public function regenerateForEntity(Entity $entity): void
{ {
$entity->rebuildPermissions(); $entity->rebuildPermissions();
$entity->load('jointPermissions');
} }
/** /**

View File

@ -2,7 +2,6 @@
namespace Tests; namespace Tests;
use BookStack\Auth\Permissions\JointPermissionBuilder;
use BookStack\Auth\Permissions\RolePermission; use BookStack\Auth\Permissions\RolePermission;
use BookStack\Auth\Role; use BookStack\Auth\Role;
use BookStack\Auth\User; use BookStack\Auth\User;