--- /dev/null
+<?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';
+}
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()
{
// 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;
}
}
}
- $this->buildJointPermissionsForEntities($entities);
+ $this->buildForEntities($entities);
}
/**
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;
}
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);
}
/**
+ * Convert the given list of entities into "SimpleEntityData" representations
+ * for faster usage and property access.
+ *
* @param Entity[] $entities
*
* @return SimpleEntityData[]
$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;
protected function generateCollapsedPermissions(array $originalEntities)
{
$entities = $this->entitiesToSimpleEntities($originalEntities);
- $jointPermissions = [];
+ $collapsedPermData = [];
// Fetch related entity permissions
$permissions = $this->getEntityPermissionsForEntities($entities);
// 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);
}
});
}
{
$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 = [];
+++ /dev/null
-<?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');
- }
-}
+++ /dev/null
-<?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');
- }
-}
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;
}
}
$role->entityPermissions()->delete();
- $role->jointPermissions()->delete();
+ $role->collapsedPermissions()->delete();
Activity::add(ActivityType::ROLE_DELETE, $role);
$role->delete();
}
{
public int $id;
public string $type;
- public int $owned_by;
public ?int $book_id;
public ?int $chapter_id;
}
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;
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.
*/
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.
*/
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;
}, '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.
*/
$user->apiTokens()->delete();
$user->favourites()->delete();
$user->mfaValues()->delete();
+ $user->collapsedPermissions()->delete();
+ $user->entityPermissions()->delete();
$user->delete();
// Delete user profile images
namespace BookStack\Console\Commands;
-use BookStack\Auth\Permissions\JointPermissionBuilder;
+use BookStack\Auth\Permissions\CollapsedPermissionBuilder;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
*/
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();
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;
}
/**
- * 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');
}
/**
*/
public function rebuildPermissions()
{
- app()->make(JointPermissionBuilder::class)->rebuildForEntity(clone $this);
+ app()->make(CollapsedPermissionBuilder::class)->rebuildForEntity(clone $this);
}
/**
$entity->permissions()->delete();
$entity->tags()->delete();
$entity->comments()->delete();
- $entity->jointPermissions()->delete();
+ $entity->collapsedPermissions()->delete();
$entity->searchTerms()->delete();
$entity->deletions()->delete();
$entity->favourites()->delete();
}
/**
- * 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
{
*/
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();
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;
]);
$token->save();
- app(JointPermissionBuilder::class)->rebuildForAll();
+ app(CollapsedPermissionBuilder::class)->rebuildForAll();
app(SearchIndex::class)->indexAllEntities();
}
}
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;
$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);
}
}
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;
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();
}
}
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;
public function regenerateForEntity(Entity $entity): void
{
$entity->rebuildPermissions();
- $entity->load('jointPermissions');
}
/**
namespace Tests;
-use BookStack\Auth\Permissions\JointPermissionBuilder;
use BookStack\Auth\Permissions\RolePermission;
use BookStack\Auth\Role;
use BookStack\Auth\User;