Merge pull request #2899 from BookStackApp/export_permissions

Added role permissions for exporting content
This commit is contained in:
Dan Brown 2021-08-28 21:57:11 +01:00 committed by GitHub
commit 8f0d08763a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 197 additions and 36 deletions

View File

@ -13,6 +13,7 @@ class BookExportApiController extends ApiController
public function __construct(ExportFormatter $exportFormatter)
{
$this->exportFormatter = $exportFormatter;
$this->middleware('can:content-export');
}
/**

View File

@ -16,6 +16,7 @@ class ChapterExportApiController extends ApiController
public function __construct(ExportFormatter $exportFormatter)
{
$this->exportFormatter = $exportFormatter;
$this->middleware('can:content-export');
}
/**

View File

@ -13,6 +13,7 @@ class PageExportApiController extends ApiController
public function __construct(ExportFormatter $exportFormatter)
{
$this->exportFormatter = $exportFormatter;
$this->middleware('can:content-export');
}
/**

View File

@ -18,6 +18,7 @@ class BookExportController extends Controller
{
$this->bookRepo = $bookRepo;
$this->exportFormatter = $exportFormatter;
$this->middleware('can:content-export');
}
/**

View File

@ -19,6 +19,7 @@ class ChapterExportController extends Controller
{
$this->chapterRepo = $chapterRepo;
$this->exportFormatter = $exportFormatter;
$this->middleware('can:content-export');
}
/**

View File

@ -20,6 +20,7 @@ class PageExportController extends Controller
{
$this->pageRepo = $pageRepo;
$this->exportFormatter = $exportFormatter;
$this->middleware('can:content-export');
}
/**

View File

@ -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,
];

View 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('/');
}
}

View File

@ -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);
}
}

View File

@ -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();
}
}

View File

@ -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.',

View File

@ -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>

View File

@ -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

View File

@ -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>

View File

@ -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">

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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.'])
)
);
}
/**