mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-08-26 20:22:18 +00:00
Merge pull request #2899 from BookStackApp/export_permissions
Added role permissions for exporting content
This commit is contained in:
commit
8f0d08763a
@ -13,6 +13,7 @@ class BookExportApiController extends ApiController
|
||||
public function __construct(ExportFormatter $exportFormatter)
|
||||
{
|
||||
$this->exportFormatter = $exportFormatter;
|
||||
$this->middleware('can:content-export');
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -16,6 +16,7 @@ class ChapterExportApiController extends ApiController
|
||||
public function __construct(ExportFormatter $exportFormatter)
|
||||
{
|
||||
$this->exportFormatter = $exportFormatter;
|
||||
$this->middleware('can:content-export');
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -13,6 +13,7 @@ class PageExportApiController extends ApiController
|
||||
public function __construct(ExportFormatter $exportFormatter)
|
||||
{
|
||||
$this->exportFormatter = $exportFormatter;
|
||||
$this->middleware('can:content-export');
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -18,6 +18,7 @@ class BookExportController extends Controller
|
||||
{
|
||||
$this->bookRepo = $bookRepo;
|
||||
$this->exportFormatter = $exportFormatter;
|
||||
$this->middleware('can:content-export');
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -19,6 +19,7 @@ class ChapterExportController extends Controller
|
||||
{
|
||||
$this->chapterRepo = $chapterRepo;
|
||||
$this->exportFormatter = $exportFormatter;
|
||||
$this->middleware('can:content-export');
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -20,6 +20,7 @@ class PageExportController extends Controller
|
||||
{
|
||||
$this->pageRepo = $pageRepo;
|
||||
$this->exportFormatter = $exportFormatter;
|
||||
$this->middleware('can:content-export');
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -48,10 +48,9 @@ class Kernel extends HttpKernel
|
||||
*/
|
||||
protected $routeMiddleware = [
|
||||
'auth' => \BookStack\Http\Middleware\Authenticate::class,
|
||||
'can' => \Illuminate\Auth\Middleware\Authorize::class,
|
||||
'can' => \BookStack\Http\Middleware\CheckUserHasPermission::class,
|
||||
'guest' => \BookStack\Http\Middleware\RedirectIfAuthenticated::class,
|
||||
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
|
||||
'perm' => \BookStack\Http\Middleware\PermissionMiddleware::class,
|
||||
'guard' => \BookStack\Http\Middleware\CheckGuard::class,
|
||||
'mfa-setup' => \BookStack\Http\Middleware\AuthenticatedOrPendingMfa::class,
|
||||
];
|
||||
|
38
app/Http/Middleware/CheckUserHasPermission.php
Normal file
38
app/Http/Middleware/CheckUserHasPermission.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CheckUserHasPermission
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param \Closure $next
|
||||
* @param $permission
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle($request, Closure $next, $permission)
|
||||
{
|
||||
if (!user()->can($permission)) {
|
||||
return $this->errorResponse($request);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
protected function errorResponse(Request $request)
|
||||
{
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['error' => trans('errors.permissionJson')], 403);
|
||||
}
|
||||
|
||||
session()->flash('error', trans('errors.permission'));
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
}
|
@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
|
||||
class PermissionMiddleware
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param \Closure $next
|
||||
* @param $permission
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle($request, Closure $next, $permission)
|
||||
{
|
||||
if (!$request->user() || !$request->user()->can($permission)) {
|
||||
session()->flash('error', trans('errors.permission'));
|
||||
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AddExportRolePermission extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
// Create new templates-manage permission and assign to admin role
|
||||
$roles = DB::table('roles')->get('id');
|
||||
$permissionId = DB::table('role_permissions')->insertGetId([
|
||||
'name' => 'content-export',
|
||||
'display_name' => 'Export Content',
|
||||
'created_at' => Carbon::now()->toDateTimeString(),
|
||||
'updated_at' => Carbon::now()->toDateTimeString(),
|
||||
]);
|
||||
|
||||
$permissionRoles = $roles->map(function ($role) use ($permissionId) {
|
||||
return [
|
||||
'role_id' => $role->id,
|
||||
'permission_id' => $permissionId,
|
||||
];
|
||||
})->values()->toArray();
|
||||
|
||||
DB::table('permission_role')->insert($permissionRoles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
// Remove content-export permission
|
||||
$contentExportPermission = DB::table('role_permissions')
|
||||
->where('name', '=', 'content-export')->first();
|
||||
|
||||
DB::table('permission_role')->where('permission_id', '=', $contentExportPermission->id)->delete();
|
||||
DB::table('role_permissions')->where('id', '=', 'content-export')->delete();
|
||||
}
|
||||
}
|
@ -148,6 +148,7 @@ return [
|
||||
'role_manage_page_templates' => 'Manage page templates',
|
||||
'role_access_api' => 'Access system API',
|
||||
'role_manage_settings' => 'Manage app settings',
|
||||
'role_export_content' => 'Export content',
|
||||
'role_asset' => 'Asset Permissions',
|
||||
'roles_system_warning' => 'Be aware that access to any of the above three permissions can allow a user to alter their own privileges or the privileges of others in the system. Only assign roles with these permissions to trusted users.',
|
||||
'role_asset_desc' => 'These permissions control default access to the assets within the system. Permissions on Books, Chapters and Pages will override these permissions.',
|
||||
|
@ -128,7 +128,9 @@
|
||||
@if(signedInUser())
|
||||
@include('entities.favourite-action', ['entity' => $book])
|
||||
@endif
|
||||
@include('entities.export-menu', ['entity' => $book])
|
||||
@if(userCan('content-export'))
|
||||
@include('entities.export-menu', ['entity' => $book])
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -132,7 +132,9 @@
|
||||
@if(signedInUser())
|
||||
@include('entities.favourite-action', ['entity' => $chapter])
|
||||
@endif
|
||||
@include('entities.export-menu', ['entity' => $chapter])
|
||||
@if(userCan('content-export'))
|
||||
@include('entities.export-menu', ['entity' => $chapter])
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@stop
|
||||
|
@ -165,7 +165,9 @@
|
||||
@if(signedInUser())
|
||||
@include('entities.favourite-action', ['entity' => $page])
|
||||
@endif
|
||||
@include('entities.export-menu', ['entity' => $page])
|
||||
@if(userCan('content-export'))
|
||||
@include('entities.export-menu', ['entity' => $page])
|
||||
@endif
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
@ -41,6 +41,7 @@
|
||||
<div>@include('settings.roles.parts.checkbox', ['permission' => 'restrictions-manage-own', 'label' => trans('settings.role_manage_own_entity_permissions')])</div>
|
||||
<div>@include('settings.roles.parts.checkbox', ['permission' => 'templates-manage', 'label' => trans('settings.role_manage_page_templates')])</div>
|
||||
<div>@include('settings.roles.parts.checkbox', ['permission' => 'access-api', 'label' => trans('settings.role_access_api')])</div>
|
||||
<div>@include('settings.roles.parts.checkbox', ['permission' => 'content-export', 'label' => trans('settings.role_export_content')])</div>
|
||||
</div>
|
||||
<div>
|
||||
<div>@include('settings.roles.parts.checkbox', ['permission' => 'settings-manage', 'label' => trans('settings.role_manage_settings')])</div>
|
||||
@ -239,7 +240,7 @@
|
||||
|
||||
<div class="card content-wrap auto-height">
|
||||
<h2 class="list-heading">{{ trans('settings.role_users') }}</h2>
|
||||
@if(isset($role) && count($role->users) > 0)
|
||||
@if(count($role->users ?? []) > 0)
|
||||
<div class="grid third">
|
||||
@foreach($role->users as $user)
|
||||
<div class="user-list-item">
|
||||
|
@ -155,4 +155,17 @@ class BooksApiTest extends TestCase
|
||||
$resp->assertSee('# ' . $book->pages()->first()->name);
|
||||
$resp->assertSee('# ' . $book->chapters()->first()->name);
|
||||
}
|
||||
|
||||
public function test_cant_export_when_not_have_permission()
|
||||
{
|
||||
$types = ['html', 'plaintext', 'pdf', 'markdown'];
|
||||
$this->actingAsApiEditor();
|
||||
$this->removePermissionFromUser($this->getEditor(), 'content-export');
|
||||
|
||||
$book = Book::visible()->first();
|
||||
foreach ($types as $type) {
|
||||
$resp = $this->get($this->baseEndpoint . "/{$book->id}/export/{$type}");
|
||||
$this->assertPermissionError($resp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -200,4 +200,17 @@ class ChaptersApiTest extends TestCase
|
||||
$resp->assertSee('# ' . $chapter->name);
|
||||
$resp->assertSee('# ' . $chapter->pages()->first()->name);
|
||||
}
|
||||
|
||||
public function test_cant_export_when_not_have_permission()
|
||||
{
|
||||
$types = ['html', 'plaintext', 'pdf', 'markdown'];
|
||||
$this->actingAsApiEditor();
|
||||
$this->removePermissionFromUser($this->getEditor(), 'content-export');
|
||||
|
||||
$chapter = Chapter::visible()->has('pages')->first();
|
||||
foreach ($types as $type) {
|
||||
$resp = $this->get($this->baseEndpoint . "/{$chapter->id}/export/{$type}");
|
||||
$this->assertPermissionError($resp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -292,4 +292,17 @@ class PagesApiTest extends TestCase
|
||||
$resp->assertSee('# ' . $page->name);
|
||||
$resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.md"');
|
||||
}
|
||||
|
||||
public function test_cant_export_when_not_have_permission()
|
||||
{
|
||||
$types = ['html', 'plaintext', 'pdf', 'markdown'];
|
||||
$this->actingAsApiEditor();
|
||||
$this->removePermissionFromUser($this->getEditor(), 'content-export');
|
||||
|
||||
$page = Page::visible()->first();
|
||||
foreach ($types as $type) {
|
||||
$resp = $this->get($this->baseEndpoint . "/{$page->id}/export/{$type}");
|
||||
$this->assertPermissionError($resp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
namespace Tests\Entity;
|
||||
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
@ -340,4 +341,29 @@ class ExportTest extends TestCase
|
||||
$resp->assertSee('# ' . $chapter->name);
|
||||
$resp->assertSee('# ' . $page->name);
|
||||
}
|
||||
|
||||
public function test_export_option_only_visible_and_accessible_with_permission()
|
||||
{
|
||||
$book = Book::query()->whereHas('pages')->whereHas('chapters')->first();
|
||||
$chapter = $book->chapters()->first();
|
||||
$page = $chapter->pages()->first();
|
||||
$entities = [$book, $chapter, $page];
|
||||
$user = $this->getViewer();
|
||||
$this->actingAs($user);
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
$resp = $this->get($entity->getUrl());
|
||||
$resp->assertSee('/export/pdf');
|
||||
}
|
||||
|
||||
/** @var Role $role */
|
||||
$this->removePermissionFromUser($user, 'content-export');
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
$resp = $this->get($entity->getUrl());
|
||||
$resp->assertDontSee('/export/pdf');
|
||||
$resp = $this->get($entity->getUrl('/export/pdf'));
|
||||
$this->assertPermissionError($resp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ namespace Tests;
|
||||
|
||||
use BookStack\Auth\Permissions\PermissionService;
|
||||
use BookStack\Auth\Permissions\PermissionsRepo;
|
||||
use BookStack\Auth\Permissions\RolePermission;
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Models\Book;
|
||||
@ -18,6 +19,7 @@ use BookStack\Entities\Repos\PageRepo;
|
||||
use BookStack\Settings\SettingService;
|
||||
use BookStack\Uploads\HttpFetcher;
|
||||
use Illuminate\Foundation\Testing\Assert as PHPUnit;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Env;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Mockery;
|
||||
@ -184,6 +186,19 @@ trait SharedTestHelpers
|
||||
$user->clearPermissionCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Completely remove the given permission name from the given user.
|
||||
*/
|
||||
protected function removePermissionFromUser(User $user, string $permission)
|
||||
{
|
||||
$permission = RolePermission::query()->where('name', '=', $permission)->first();
|
||||
/** @var Role $role */
|
||||
foreach ($user->roles as $role) {
|
||||
$role->detachPermission($permission);
|
||||
}
|
||||
$user->clearPermissionCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new basic role for testing purposes.
|
||||
*/
|
||||
@ -274,8 +289,17 @@ trait SharedTestHelpers
|
||||
private function isPermissionError($response): bool
|
||||
{
|
||||
return $response->status() === 302
|
||||
&& $response->headers->get('Location') === url('/')
|
||||
&& strpos(session()->pull('error', ''), 'You do not have permission to access') === 0;
|
||||
&& (
|
||||
(
|
||||
$response->headers->get('Location') === url('/')
|
||||
&& strpos(session()->pull('error', ''), 'You do not have permission to access') === 0
|
||||
)
|
||||
||
|
||||
(
|
||||
$response instanceof JsonResponse &&
|
||||
$response->json(['error' => 'You do not have permission to perform the requested action.'])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
Loading…
Reference in New Issue
Block a user