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;
/**
* Joint permissions provide a pre-query "cached" table of view permissions for all core entity
* types for all roles in the system. This class generates out that table for different scenarios.
* Collapsed permissions act as a "flattened" view of entity-level permissions in the system
* 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()
{
@ -27,26 +27,26 @@ class JointPermissionBuilder
// Chunk through all books
$this->bookFetchQuery()->chunk(5, function (EloquentCollection $books) {
$this->buildJointPermissionsForBooks($books);
$this->buildForBooks($books, false);
});
// Chunk through all bookshelves
Bookshelf::query()->withTrashed()
->select(['id', 'owned_by'])
->select(['id'])
->chunk(50, function (EloquentCollection $shelves) {
$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)
{
$entities = [$entity];
if ($entity instanceof Book) {
$books = $this->bookFetchQuery()->where('id', '=', $entity->id)->get();
$this->buildJointPermissionsForBooks($books, true);
$this->buildForBooks($books, true);
return;
}
@ -66,7 +66,7 @@ class JointPermissionBuilder
}
}
$this->buildJointPermissionsForEntities($entities);
$this->buildForEntities($entities);
}
/**
@ -75,20 +75,20 @@ class JointPermissionBuilder
protected function bookFetchQuery(): Builder
{
return Book::query()->withTrashed()
->select(['id', 'owned_by'])->with([
->select(['id'])->with([
'chapters' => function ($query) {
$query->withTrashed()->select(['id', 'owned_by', 'book_id']);
$query->withTrashed()->select(['id', 'book_id']);
},
'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;
@ -103,27 +103,27 @@ class JointPermissionBuilder
}
if ($deleteOld) {
$this->deleteManyJointPermissionsForEntities($entities->all());
$this->deleteForEntities($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);
}
/**
* Delete all the entity jointPermissions for a list of entities.
* Delete the stored collapsed permissions for a list of entities.
*
* @param Entity[] $entities
*/
protected function deleteManyJointPermissionsForEntities(array $entities)
protected function deleteForEntities(array $entities)
{
$simpleEntities = $this->entitiesToSimpleEntities($entities);
$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
*
* @return SimpleEntityData[]
@ -154,7 +157,6 @@ class JointPermissionBuilder
$simple = new SimpleEntityData();
$simple->id = $attrs['id'];
$simple->type = $entity->getMorphClass();
$simple->owned_by = $attrs['owned_by'] ?? 0;
$simple->book_id = $attrs['book_id'] ?? null;
$simple->chapter_id = $attrs['chapter_id'] ?? null;
$simpleEntities[] = $simple;
@ -171,7 +173,7 @@ class JointPermissionBuilder
protected function generateCollapsedPermissions(array $originalEntities)
{
$entities = $this->entitiesToSimpleEntities($originalEntities);
$jointPermissions = [];
$collapsedPermData = [];
// Fetch related entity permissions
$permissions = $this->getEntityPermissionsForEntities($entities);
@ -181,12 +183,12 @@ class JointPermissionBuilder
// Create Joint Permission Data
foreach ($entities as $entity) {
array_push($jointPermissions, ...$this->createCollapsedPermissionData($entity, $permissionMap));
array_push($collapsedPermData, ...$this->createCollapsedPermissionData($entity, $permissionMap));
}
DB::transaction(function () use ($jointPermissions) {
foreach (array_chunk($jointPermissions, 1000) as $jointPermissionChunk) {
DB::table('entity_permissions_collapsed')->insert($jointPermissionChunk);
DB::transaction(function () use ($collapsedPermData) {
foreach (array_chunk($collapsedPermData, 1000) as $dataChunk) {
DB::table('entity_permissions_collapsed')->insert($dataChunk);
}
});
}
@ -198,8 +200,8 @@ class JointPermissionBuilder
{
$chain = [
$entity->type . ':' . $entity->id,
$entity->chapter_id ? null : ('chapter:' . $entity->chapter_id),
$entity->book_id ? null : ('book:' . $entity->book_id),
$entity->chapter_id ? ('chapter:' . $entity->chapter_id) : null,
$entity->book_id ? ('book:' . $entity->book_id) : null,
];
$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
{
protected JointPermissionBuilder $permissionBuilder;
protected $systemRoles = ['admin', 'public'];
protected CollapsedPermissionBuilder $permissionBuilder;
protected array $systemRoles = ['admin', 'public'];
/**
* PermissionsRepo constructor.
*/
public function __construct(JointPermissionBuilder $permissionBuilder)
public function __construct(CollapsedPermissionBuilder $permissionBuilder)
{
$this->permissionBuilder = $permissionBuilder;
}
@ -138,7 +138,7 @@ class PermissionsRepo
}
$role->entityPermissions()->delete();
$role->jointPermissions()->delete();
$role->collapsedPermissions()->delete();
Activity::add(ActivityType::ROLE_DELETE, $role);
$role->delete();
}

View File

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

View File

@ -2,8 +2,8 @@
namespace BookStack\Auth;
use BookStack\Auth\Permissions\CollapsedPermission;
use BookStack\Auth\Permissions\EntityPermission;
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Auth\Permissions\RolePermission;
use BookStack\Interfaces\Loggable;
use BookStack\Model;
@ -39,14 +39,6 @@ class Role extends Model implements Loggable
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.
*/
@ -63,6 +55,14 @@ class Role extends Model implements Loggable
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.
*/

View File

@ -5,6 +5,8 @@ namespace BookStack\Auth;
use BookStack\Actions\Favourite;
use BookStack\Api\ApiToken;
use BookStack\Auth\Access\Mfa\MfaValue;
use BookStack\Auth\Permissions\CollapsedPermission;
use BookStack\Auth\Permissions\EntityPermission;
use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Interfaces\Loggable;
use BookStack\Interfaces\Sluggable;
@ -298,6 +300,22 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
}, '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.
*/

View File

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

View File

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

View File

@ -7,10 +7,9 @@ use BookStack\Actions\Comment;
use BookStack\Actions\Favourite;
use BookStack\Actions\Tag;
use BookStack\Actions\View;
use BookStack\Auth\Permissions\CollapsedPermission;
use BookStack\Auth\Permissions\EntityPermission;
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Auth\Permissions\JointPermissionBuilder;
use BookStack\Auth\Permissions\JointUserPermission;
use BookStack\Auth\Permissions\CollapsedPermissionBuilder;
use BookStack\Auth\Permissions\PermissionApplicator;
use BookStack\Entities\Tools\SlugGenerator;
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');
}
/**
* Get the join user permissions for this entity.
*/
public function jointUserPermissions(): MorphMany
{
return $this->morphMany(JointUserPermission::class, 'entity');
return $this->morphMany(CollapsedPermission::class, 'entity');
}
/**
@ -301,7 +292,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
*/
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->tags()->delete();
$entity->comments()->delete();
$entity->jointPermissions()->delete();
$entity->collapsedPermissions()->delete();
$entity->searchTerms()->delete();
$entity->deletions()->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
* is passed in the jointPermissions are checked against that particular item.
* Check if the current user has a permission.
* 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
{

View File

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

View File

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

View File

@ -2,7 +2,7 @@
namespace Database\Seeders;
use BookStack\Auth\Permissions\JointPermissionBuilder;
use BookStack\Auth\Permissions\CollapsedPermissionBuilder;
use BookStack\Auth\Role;
use BookStack\Auth\User;
use BookStack\Entities\Models\Book;
@ -35,7 +35,7 @@ class LargeContentSeeder extends Seeder
$largeBook->chapters()->saveMany($chapters);
$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);
}
}

View File

@ -2,8 +2,7 @@
namespace Tests\Commands;
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Entities\Models\Page;
use BookStack\Auth\Permissions\CollapsedPermission;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
@ -13,15 +12,23 @@ class RegeneratePermissionsCommandTest extends TestCase
public function test_regen_permissions_command()
{
DB::rollBack();
JointPermission::query()->truncate();
$page = Page::first();
$page = $this->entities->page();
$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');
$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;
use BookStack\Auth\Permissions\EntityPermission;
use BookStack\Auth\Permissions\JointPermissionBuilder;
use BookStack\Auth\Permissions\RolePermission;
use BookStack\Auth\Role;
use BookStack\Auth\User;
@ -70,7 +69,6 @@ class PermissionsProvider
public function regenerateForEntity(Entity $entity): void
{
$entity->rebuildPermissions();
$entity->load('jointPermissions');
}
/**

View File

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