Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Content-Permissions API Endpoints #4099

Merged
merged 3 commits into from Mar 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
17 changes: 8 additions & 9 deletions app/Auth/Permissions/EntityPermission.php
Expand Up @@ -5,7 +5,6 @@
use BookStack\Auth\Role;
use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;

/**
* @property int $id
Expand All @@ -23,14 +22,14 @@ class EntityPermission extends Model

protected $fillable = ['role_id', 'view', 'create', 'update', 'delete'];
public $timestamps = false;

/**
* Get this restriction's attached entity.
*/
public function restrictable(): MorphTo
{
return $this->morphTo('restrictable');
}
protected $hidden = ['entity_id', 'entity_type', 'id'];
protected $casts = [
'view' => 'boolean',
'create' => 'boolean',
'read' => 'boolean',
'update' => 'boolean',
'delete' => 'boolean',
];

/**
* Get the role assigned to this entity permission.
Expand Down
38 changes: 12 additions & 26 deletions app/Entities/EntityProvider.php
Expand Up @@ -18,30 +18,11 @@
*/
class EntityProvider
{
/**
* @var Bookshelf
*/
public $bookshelf;

/**
* @var Book
*/
public $book;

/**
* @var Chapter
*/
public $chapter;

/**
* @var Page
*/
public $page;

/**
* @var PageRevision
*/
public $pageRevision;
public Bookshelf $bookshelf;
public Book $book;
public Chapter $chapter;
public Page $page;
public PageRevision $pageRevision;

public function __construct()
{
Expand Down Expand Up @@ -69,13 +50,18 @@ public function all(): array
}

/**
* Get an entity instance by it's basic name.
* Get an entity instance by its basic name.
*/
public function get(string $type): Entity
{
$type = strtolower($type);
$instance = $this->all()[$type] ?? null;

if (is_null($instance)) {
throw new \InvalidArgumentException("Provided type \"{$type}\" is not a valid entity type");
}

return $this->all()[$type];
return $instance;
}

/**
Expand Down
74 changes: 70 additions & 4 deletions app/Entities/Tools/PermissionsUpdater.php
Expand Up @@ -4,20 +4,20 @@

use BookStack\Actions\ActivityType;
use BookStack\Auth\Permissions\EntityPermission;
use BookStack\Auth\Role;
use BookStack\Auth\User;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Entity;
use BookStack\Facades\Activity;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;

class PermissionsUpdater
{
/**
* Update an entities permissions from a permission form submit request.
*/
public function updateFromPermissionsForm(Entity $entity, Request $request)
public function updateFromPermissionsForm(Entity $entity, Request $request): void
{
$permissions = $request->get('permissions', null);
$ownerId = $request->get('owned_by', null);
Expand All @@ -39,12 +39,44 @@ public function updateFromPermissionsForm(Entity $entity, Request $request)
Activity::add(ActivityType::PERMISSIONS_UPDATE, $entity);
}

/**
* Update permissions from API request data.
*/
public function updateFromApiRequestData(Entity $entity, array $data): void
{
if (isset($data['role_permissions'])) {
$entity->permissions()->where('role_id', '!=', 0)->delete();
$rolePermissionData = $this->formatPermissionsFromApiRequestToEntityPermissions($data['role_permissions'] ?? [], false);
$entity->permissions()->createMany($rolePermissionData);
}

if (array_key_exists('fallback_permissions', $data)) {
$entity->permissions()->where('role_id', '=', 0)->delete();
}

if (isset($data['fallback_permissions']['inheriting']) && $data['fallback_permissions']['inheriting'] !== true) {
$data = $data['fallback_permissions'];
$data['role_id'] = 0;
$rolePermissionData = $this->formatPermissionsFromApiRequestToEntityPermissions([$data], true);
$entity->permissions()->createMany($rolePermissionData);
}

if (isset($data['owner_id'])) {
$this->updateOwnerFromId($entity, intval($data['owner_id']));
}

$entity->save();
$entity->rebuildPermissions();

Activity::add(ActivityType::PERMISSIONS_UPDATE, $entity);
}

/**
* Update the owner of the given entity.
* Checks the user exists in the system first.
* Does not save the model, just updates it.
*/
protected function updateOwnerFromId(Entity $entity, int $newOwnerId)
protected function updateOwnerFromId(Entity $entity, int $newOwnerId): void
{
$newOwner = User::query()->find($newOwnerId);
if (!is_null($newOwner)) {
Expand All @@ -67,7 +99,41 @@ protected function formatPermissionsFromRequestToEntityPermissions(array $permis
$formatted[] = $entityPermissionData;
}

return $formatted;
return $this->filterEntityPermissionDataUponRole($formatted, true);
}

protected function formatPermissionsFromApiRequestToEntityPermissions(array $permissions, bool $allowFallback): array
{
$formatted = [];

foreach ($permissions as $requestPermissionData) {
$entityPermissionData = ['role_id' => $requestPermissionData['role_id']];
foreach (EntityPermission::PERMISSIONS as $permission) {
$entityPermissionData[$permission] = boolval($requestPermissionData[$permission] ?? false);
}
$formatted[] = $entityPermissionData;
}

return $this->filterEntityPermissionDataUponRole($formatted, $allowFallback);
}

protected function filterEntityPermissionDataUponRole(array $entityPermissionData, bool $allowFallback): array
{
$roleIds = [];
foreach ($entityPermissionData as $permissionEntry) {
$roleIds[] = intval($permissionEntry['role_id']);
}

$actualRoleIds = array_unique(array_values(array_filter($roleIds)));
$rolesById = Role::query()->whereIn('id', $actualRoleIds)->get('id')->keyBy('id');

return array_values(array_filter($entityPermissionData, function ($data) use ($rolesById, $allowFallback) {
if (intval($data['role_id']) === 0) {
return $allowFallback;
}

return $rolesById->has($data['role_id']);
}));
}

/**
Expand Down
100 changes: 100 additions & 0 deletions app/Http/Controllers/Api/ContentPermissionsController.php
@@ -0,0 +1,100 @@
<?php

namespace BookStack\Http\Controllers\Api;

use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Tools\PermissionsUpdater;
use Illuminate\Http\Request;

class ContentPermissionsController extends ApiController
{
public function __construct(
protected PermissionsUpdater $permissionsUpdater,
protected EntityProvider $entities
) {
}

protected $rules = [
'update' => [
'owner_id' => ['int'],

'role_permissions' => ['array'],
'role_permissions.*.role_id' => ['required', 'int', 'exists:roles,id'],
'role_permissions.*.view' => ['required', 'boolean'],
'role_permissions.*.create' => ['required', 'boolean'],
'role_permissions.*.update' => ['required', 'boolean'],
'role_permissions.*.delete' => ['required', 'boolean'],

'fallback_permissions' => ['nullable'],
'fallback_permissions.inheriting' => ['required_with:fallback_permissions', 'boolean'],
'fallback_permissions.view' => ['required_if:fallback_permissions.inheriting,false', 'boolean'],
'fallback_permissions.create' => ['required_if:fallback_permissions.inheriting,false', 'boolean'],
'fallback_permissions.update' => ['required_if:fallback_permissions.inheriting,false', 'boolean'],
'fallback_permissions.delete' => ['required_if:fallback_permissions.inheriting,false', 'boolean'],
]
];

/**
* Read the configured content-level permissions for the item of the given type and ID.
* 'contentType' should be one of: page, book, chapter, bookshelf.
* 'contentId' should be the relevant ID of that item type you'd like to handle permissions for.
* The permissions shown are those that override the default for just the specified item, they do not show the
* full evaluated permission for a role, nor do they reflect permissions inherited from other items in the hierarchy.
* Fallback permission values may be `null` when inheriting is active.
*/
public function read(string $contentType, string $contentId)
{
$entity = $this->entities->get($contentType)
->newQuery()->scopes(['visible'])->findOrFail($contentId);

$this->checkOwnablePermission('restrictions-manage', $entity);

return response()->json($this->formattedPermissionDataForEntity($entity));
}

/**
* Update the configured content-level permission overrides for the item of the given type and ID.
* 'contentType' should be one of: page, book, chapter, bookshelf.
* 'contentId' should be the relevant ID of that item type you'd like to handle permissions for.
* Providing an empty `role_permissions` array will remove any existing configured role permissions,
* so you may want to fetch existing permissions beforehand if just adding/removing a single item.
* You should completely omit the `owner_id`, `role_permissions` and/or the `fallback_permissions` properties
* from your request data if you don't wish to update details within those categories.
*/
public function update(Request $request, string $contentType, string $contentId)
{
$entity = $this->entities->get($contentType)
->newQuery()->scopes(['visible'])->findOrFail($contentId);

$this->checkOwnablePermission('restrictions-manage', $entity);

$data = $this->validate($request, $this->rules()['update']);
$this->permissionsUpdater->updateFromApiRequestData($entity, $data);

return response()->json($this->formattedPermissionDataForEntity($entity));
}

protected function formattedPermissionDataForEntity(Entity $entity): array
{
$rolePermissions = $entity->permissions()
->where('role_id', '!=', 0)
->with(['role:id,display_name'])
->get();

$fallback = $entity->permissions()->where('role_id', '=', 0)->first();
$fallbackData = [
'inheriting' => is_null($fallback),
'view' => $fallback->view ?? null,
'create' => $fallback->create ?? null,
'update' => $fallback->update ?? null,
'delete' => $fallback->delete ?? null,
];

return [
'owner' => $entity->ownedBy()->first(),
'role_permissions' => $rolePermissions,
'fallback_permissions' => $fallbackData,
];
}
}
26 changes: 26 additions & 0 deletions dev/api/requests/content-permissions-update.json
@@ -0,0 +1,26 @@
{
"owner_id": 1,
"role_permissions": [
{
"role_id": 2,
"view": true,
"create": true,
"update": true,
"delete": false
},
{
"role_id": 3,
"view": false,
"create": false,
"update": false,
"delete": false
}
],
"fallback_permissions": {
"inheriting": false,
"view": true,
"create": true,
"update": false,
"delete": false
}
}
38 changes: 38 additions & 0 deletions dev/api/responses/content-permissions-read.json
@@ -0,0 +1,38 @@
{
"owner": {
"id": 1,
"name": "Admin",
"slug": "admin"
},
"role_permissions": [
{
"role_id": 2,
"view": true,
"create": false,
"update": true,
"delete": false,
"role": {
"id": 2,
"display_name": "Editor"
}
},
{
"role_id": 10,
"view": true,
"create": true,
"update": false,
"delete": false,
"role": {
"id": 10,
"display_name": "Wizards of the west"
}
}
],
"fallback_permissions": {
"inheriting": false,
"view": true,
"create": false,
"update": false,
"delete": false
}
}