1
0
mirror of https://github.com/flarum/core.git synced 2025-07-13 12:56:26 +02:00

Overhaul permissions

Get rid of Permissible - too complex and inefficient. Replace with:
- a “Locked” trait which works similarly but only evaluates logic on
hydrated models.
- a “VisibleScope” trait which also works similarly but only scopes
queries

This is all we need, Permissible is overkill. There is only one
instance where we have to duplicate some logic
(Discussion::scopeVisiblePosts and Post::allow(‘view’, …)) but it’s
barely anything.

Haven’t decoupled for now, we can definitely look at doing that later.

Permissions table seeder slightly updated.

Also did a bit of a query audit, there’s still a lot to be done but
it’s much better than it was. Some relatively low-hanging fruit
detailed in EloquentPostRepository.
This commit is contained in:
Toby Zerner
2015-06-16 17:33:56 +09:30
parent 27b9dbe4c4
commit f0df751465
16 changed files with 247 additions and 122 deletions

View File

@ -14,7 +14,6 @@ class CreateUsersDiscussionsTable extends Migration
public function up() public function up()
{ {
Schema::create('users_discussions', function (Blueprint $table) { Schema::create('users_discussions', function (Blueprint $table) {
$table->integer('user_id')->unsigned(); $table->integer('user_id')->unsigned();
$table->integer('discussion_id')->unsigned(); $table->integer('discussion_id')->unsigned();
$table->dateTime('read_time')->nullable(); $table->dateTime('read_time')->nullable();

View File

@ -93,7 +93,7 @@ class ShowAction extends SerializeResourceAction
$discussion = $this->discussions->findOrFail($request->get('id'), $user); $discussion = $this->discussions->findOrFail($request->get('id'), $user);
$discussion->posts_ids = $discussion->posts()->whereCan($user, 'view')->get(['id'])->fetch('id')->all(); $discussion->posts_ids = $discussion->visiblePosts($user)->lists('id');
if (in_array('posts', $request->include)) { if (in_array('posts', $request->include)) {
$length = strlen($prefix = 'posts.'); $length = strlen($prefix = 'posts.');

View File

@ -55,16 +55,7 @@ abstract class BaseSerializer extends SerializerAbstract
$data = $relation($model, $include); $data = $relation($model, $include);
} else { } else {
if ($include) { if ($include) {
if (! is_null($model->$relation)) { $data = $model->getRelation($relation);
$data = $model->$relation;
} else {
$relation = $model->$relation();
if ($relation instanceof Relation) {
$data = $relation->getResults();
} else {
$data = $relation->get();
}
}
} elseif ($many) { } elseif ($many) {
$relationIds = $relation.'_ids'; $relationIds = $relation.'_ids';
$data = $model->$relationIds ?: $model->$relation()->get(['id'])->fetch('id')->all(); $data = $model->$relationIds ?: $model->$relation()->get(['id'])->fetch('id')->all();

View File

@ -185,71 +185,78 @@ class CoreServiceProvider extends ServiceProvider
$this->extend( $this->extend(
new Permission('forum.view'), new Permission('forum.view'),
new Permission('forum.startDiscussion'), new Permission('forum.startDiscussion'),
new Permission('discussion.rename'),
new Permission('discussion.delete'),
new Permission('discussion.reply'), new Permission('discussion.reply'),
new Permission('post.edit'), new Permission('discussion.editPosts'),
new Permission('post.delete') new Permission('discussion.deletePosts'),
new Permission('discussion.rename'),
new Permission('discussion.delete')
); );
Forum::grantPermission(function ($grant, $user, $permission) { Forum::allow('*', function ($forum, $user, $action) {
return $user->hasPermission('forum.'.$permission); if ($user->hasPermission('forum.'.$action)) {
return true;
}
}); });
Post::grantPermission(function ($grant, $user, $permission) { Post::allow('*', function ($post, $user, $action) {
return $user->hasPermission('post'.$permission); if ($user->hasPermission('post.'.$action)) {
return true;
}
}); });
// Grant view access to a post only if the user can also view the // When fetching a discussion's posts: if the user doesn't have permission
// discussion which the post is in. Also, the if the post is hidden, // to moderate the discussion, then they can't see posts that have been
// the user must have edit permissions too. // hidden by someone other than themself.
Post::grantPermission('view', function ($grant) { Discussion::scopeVisiblePosts(function ($query, User $user, Discussion $discussion) {
$grant->whereCan('view', 'discussion'); if (! $discussion->can($user, 'editPosts')) {
}); $query->where(function ($query) use ($user) {
Post::demandPermission('view', function ($demand) {
$demand->whereNull('hide_user_id')
->orWhereCan('edit');
});
// Allow a user to edit their own post, unless it has been hidden by
// someone else.
Post::grantPermission('edit', function ($grant, $user) {
$grant->where('user_id', $user->id)
->where(function ($query) use ($user) {
$query->whereNull('hide_user_id') $query->whereNull('hide_user_id')
->orWhere('hide_user_id', $user->id); ->orWhere('hide_user_id', $user->id);
}); });
// @todo add limitations to time etc. according to a config setting }
}); });
User::grantPermission(function ($grant, $user, $permission) { Post::allow('view', function ($post, $user) {
return $user->hasPermission('user.'.$permission); if (! $post->hide_user_id || $post->can($user, 'edit')) {
return true;
}
}); });
// Grant view access to a user if the user can view the forum. // A post is allowed to be edited if the user has permission to moderate
User::grantPermission('view', function ($grant, $user) { // the discussion which it's in, or if they are the author and the post
$grant->whereCan('view', 'forum'); // hasn't been deleted by someone else.
Post::allow('edit', function ($post, $user) {
if ($post->discussion->can($user, 'editPosts') ||
($post->user_id == $user->id && (! $post->hide_user_id || $post->hide_user_id == $user->id))
) {
return true;
}
}); });
// Allow a user to edit their own account. User::allow('*', function ($discussion, $user, $action) {
User::grantPermission(['edit', 'delete'], function ($grant, $user) { if ($user->hasPermission('user.'.$action)) {
$grant->where('id', $user->id); return true;
}
}); });
Discussion::grantPermission(function ($grant, $user, $permission) { User::allow(['edit', 'delete'], function ($user, $actor) {
return $user->hasPermission('discussion.'.$permission); if ($user->id == $actor->id) {
return true;
}
}); });
// Grant view access to a discussion if the user can view the forum. Discussion::allow('*', function ($discussion, $user, $action) {
Discussion::grantPermission('view', function ($grant, $user) { if ($user->hasPermission('discussion.'.$action)) {
$grant->whereCan('view', 'forum'); return true;
}
}); });
// Allow a user to rename their own discussion. // Allow a user to rename their own discussion.
Discussion::grantPermission('rename', function ($grant, $user) { Discussion::allow('rename', function ($discussion, $user) {
$grant->where('start_user_id', $user->id); if ($discussion->start_user_id == $user->id) {
return true;
// @todo add limitations to time etc. according to a config setting // @todo add limitations to time etc. according to a config setting
}
}); });
} }
} }

View File

@ -1,7 +1,8 @@
<?php namespace Flarum\Core\Models; <?php namespace Flarum\Core\Models;
use Tobscure\Permissible\Permissible;
use Flarum\Core\Support\EventGenerator; use Flarum\Core\Support\EventGenerator;
use Flarum\Core\Support\Locked;
use Flarum\Core\Support\VisibleScope;
use Flarum\Core\Events\DiscussionWasDeleted; use Flarum\Core\Events\DiscussionWasDeleted;
use Flarum\Core\Events\DiscussionWasStarted; use Flarum\Core\Events\DiscussionWasStarted;
use Flarum\Core\Events\DiscussionWasRenamed; use Flarum\Core\Events\DiscussionWasRenamed;
@ -9,7 +10,8 @@ use Flarum\Core\Models\User;
class Discussion extends Model class Discussion extends Model
{ {
use Permissible; use Locked;
use VisibleScope;
/** /**
* The validation rules for this model. * The validation rules for this model.
@ -215,6 +217,24 @@ class Discussion extends Model
return $this->hasMany('Flarum\Core\Models\Post'); return $this->hasMany('Flarum\Core\Models\Post');
} }
protected static $visiblePostsScopes = [];
public static function scopeVisiblePosts($scope)
{
static::$visiblePostsScopes[] = $scope;
}
public function visiblePosts(User $user)
{
$query = $this->posts();
foreach (static::$visiblePostsScopes as $scope) {
$scope($query, $user, $this);
}
return $query;
}
/** /**
* Define the relationship with the discussion's comments. * Define the relationship with the discussion's comments.
* *
@ -297,9 +317,8 @@ class Discussion extends Model
*/ */
public function stateFor(User $user) public function stateFor(User $user)
{ {
$loadedState = array_get($this->relations, 'state'); if ($this->isRelationLoaded('state')) {
if ($loadedState && $loadedState->user_id === $user->id) { return $this->relations['state'];
return $loadedState;
} }
$state = $this->state($user)->first(); $state = $this->state($user)->first();

View File

@ -1,11 +1,11 @@
<?php namespace Flarum\Core\Models; <?php namespace Flarum\Core\Models;
use Tobscure\Permissible\Permissible; use Flarum\Core\Support\Locked;
use Flarum\Core; use Flarum\Core;
class Forum extends Model class Forum extends Model
{ {
use Permissible; use Locked;
protected static $relationships = []; protected static $relationships = [];

View File

@ -169,37 +169,22 @@ class Model extends Eloquent
return $rules; return $rules;
} }
/** public function isRelationLoaded($relation)
* Assert that the user has permission to view this model, throwing an
* exception if they don't.
*
* @param \Flarum\Core\Models\User $user
* @return void
*
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
*/
public function assertVisibleTo(User $user)
{ {
if (! $this->can($user, 'view')) { return array_key_exists($relation, $this->relations);
throw new ModelNotFoundException;
}
} }
/** public function getRelation($relation)
* Assert that the user has a certain permission for this model, throwing
* an exception if they don't.
*
* @param \Flarum\Core\Models\User $user
* @param string $permission
* @return void
*
* @throws \Flarum\Core\Exceptions\PermissionDeniedException
*/
public function assertCan(User $user, $permission)
{ {
if (! $this->can($user, $permission)) { if (isset($this->$relation)) {
throw new PermissionDeniedException; return $this->$relation;
} }
if (! $this->isRelationLoaded($relation)) {
$this->relations[$relation] = $this->$relation()->getResults();
}
return $this->relations[$relation];
} }
/** /**

View File

@ -1,11 +1,11 @@
<?php namespace Flarum\Core\Models; <?php namespace Flarum\Core\Models;
use Tobscure\Permissible\Permissible;
use Flarum\Core\Events\PostWasDeleted; use Flarum\Core\Events\PostWasDeleted;
use Flarum\Core\Support\Locked;
class Post extends Model class Post extends Model
{ {
use Permissible; use Locked;
/** /**
* The validation rules for this model. * The validation rules for this model.

View File

@ -1,7 +1,6 @@
<?php namespace Flarum\Core\Models; <?php namespace Flarum\Core\Models;
use Illuminate\Contracts\Hashing\Hasher; use Illuminate\Contracts\Hashing\Hasher;
use Tobscure\Permissible\Permissible;
use Flarum\Core\Formatter\FormatterManager; use Flarum\Core\Formatter\FormatterManager;
use Flarum\Core\Exceptions\InvalidConfirmationTokenException; use Flarum\Core\Exceptions\InvalidConfirmationTokenException;
use Flarum\Core\Events\UserWasDeleted; use Flarum\Core\Events\UserWasDeleted;
@ -14,10 +13,13 @@ use Flarum\Core\Events\UserAvatarWasChanged;
use Flarum\Core\Events\UserWasActivated; use Flarum\Core\Events\UserWasActivated;
use Flarum\Core\Events\UserEmailWasConfirmed; use Flarum\Core\Events\UserEmailWasConfirmed;
use Flarum\Core\Events\UserEmailChangeWasRequested; use Flarum\Core\Events\UserEmailChangeWasRequested;
use Flarum\Core\Support\Locked;
use Flarum\Core\Support\VisibleScope;
class User extends Model class User extends Model
{ {
use Permissible; use Locked;
use VisibleScope;
/** /**
* The text formatter instance. * The text formatter instance.

View File

@ -30,7 +30,7 @@ class EloquentDiscussionRepository implements DiscussionRepositoryInterface
{ {
$query = Discussion::where('id', $id); $query = Discussion::where('id', $id);
return $this->scopeVisibleForUser($query, $user)->firstOrFail(); return $this->scopeVisibleTo($query, $user)->firstOrFail();
} }
/** /**
@ -54,10 +54,10 @@ class EloquentDiscussionRepository implements DiscussionRepositoryInterface
* @param \Flarum\Core\Models\User $user * @param \Flarum\Core\Models\User $user
* @return \Illuminate\Database\Eloquent\Builder * @return \Illuminate\Database\Eloquent\Builder
*/ */
protected function scopeVisibleForUser(Builder $query, User $user = null) protected function scopeVisibleTo(Builder $query, User $user = null)
{ {
if ($user !== null) { if ($user !== null) {
$query->whereCan($user, 'view'); $query->whereVisibleTo($user);
} }
return $query; return $query;

View File

@ -1,10 +1,32 @@
<?php namespace Flarum\Core\Repositories; <?php namespace Flarum\Core\Repositories;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Flarum\Core\Models\Post; use Flarum\Core\Models\Post;
use Flarum\Core\Models\User; use Flarum\Core\Models\User;
use Flarum\Core\Models\Discussion;
use Flarum\Core\Search\Discussions\Fulltext\DriverInterface; use Flarum\Core\Search\Discussions\Fulltext\DriverInterface;
// TODO: In some cases, the use of a post repository incurs extra query expense,
// because for every post retrieved we need to check if the discussion it's in
// is visible. Especially when retrieving a discussion's posts, we can end up
// with an inefficient chain of queries like this:
// 1. Api\Discussions\ShowAction: get discussion (will exit if not visible)
// 2. Discussion@visiblePosts: get discussion tags (for post visibility purposes)
// 3. Discussion@visiblePosts: get post IDs
// 4. EloquentPostRepository@getIndexForNumber: get discussion
// 5. EloquentPostRepository@getIndexForNumber: get discussion tags (for post visibility purposes)
// 6. EloquentPostRepository@getIndexForNumber: get post index for number
// 7. EloquentPostRepository@findWhere: get post IDs for discussion to check for discussion visibility
// 8. EloquentPostRepository@findWhere: get post IDs in visible discussions
// 9. EloquentPostRepository@findWhere: get posts
// 10. EloquentPostRepository@findWhere: eager load discussion onto posts
// 11. EloquentPostRepository@findWhere: get discussion tags to filter visible posts
// 12. Api\Discussions\ShowAction: eager load users
// 13. Api\Discussions\ShowAction: eager load groups
// 14. Api\Discussions\ShowAction: eager load mentions
// 14. Serializers\DiscussionSerializer: load discussion-user state
class EloquentPostRepository implements PostRepositoryInterface class EloquentPostRepository implements PostRepositoryInterface
{ {
protected $fulltext; protected $fulltext;
@ -26,9 +48,13 @@ class EloquentPostRepository implements PostRepositoryInterface
*/ */
public function findOrFail($id, User $user = null) public function findOrFail($id, User $user = null)
{ {
$query = Post::where('id', $id); $posts = $this->findByIds([$id], $user);
return $this->scopeVisibleForUser($query, $user)->firstOrFail(); if (! count($posts)) {
throw new ModelNotFoundException;
}
return $posts->first();
} }
/** /**
@ -52,7 +78,9 @@ class EloquentPostRepository implements PostRepositoryInterface
$query->orderBy($field, $order); $query->orderBy($field, $order);
} }
return $this->scopeVisibleForUser($query, $user)->get(); $ids = $query->lists('id');
return $this->findByIds($ids, $user);
} }
/** /**
@ -65,9 +93,11 @@ class EloquentPostRepository implements PostRepositoryInterface
*/ */
public function findByIds(array $ids, User $user = null) public function findByIds(array $ids, User $user = null)
{ {
$query = Post::whereIn('id', (array) $ids); $ids = $this->filterDiscussionVisibleTo($ids, $user);
return $this->scopeVisibleForUser($query, $user)->get(); $posts = Post::with('discussion')->whereIn('id', (array) $ids)->get();
return $this->filterVisibleTo($posts, $user);
} }
/** /**
@ -82,13 +112,17 @@ class EloquentPostRepository implements PostRepositoryInterface
{ {
$ids = $this->fulltext->match($string); $ids = $this->fulltext->match($string);
$ids = $this->filterDiscussionVisibleTo($ids, $user);
$query = Post::select('id', 'discussion_id')->whereIn('id', $ids); $query = Post::select('id', 'discussion_id')->whereIn('id', $ids);
foreach ($ids as $id) { foreach ($ids as $id) {
$query->orderByRaw('id != ?', [$id]); $query->orderByRaw('id != ?', [$id]);
} }
return $this->scopeVisibleForUser($query, $user)->get(); $posts = $query->get();
return $this->filterVisibleTo($posts, $user);
} }
/** /**
@ -103,7 +137,8 @@ class EloquentPostRepository implements PostRepositoryInterface
*/ */
public function getIndexForNumber($discussionId, $number, User $user = null) public function getIndexForNumber($discussionId, $number, User $user = null)
{ {
$query = Post::where('discussion_id', $discussionId) $query = Discussion::find($discussionId)
->visiblePosts($user)
->where('time', '<', function ($query) use ($discussionId, $number) { ->where('time', '<', function ($query) use ($discussionId, $number) {
$query->select('time') $query->select('time')
->from('posts') ->from('posts')
@ -116,22 +151,32 @@ class EloquentPostRepository implements PostRepositoryInterface
->orderByRaw('ABS(CAST(number AS SIGNED) - '.(int) $number.')'); ->orderByRaw('ABS(CAST(number AS SIGNED) - '.(int) $number.')');
}); });
return $this->scopeVisibleForUser($query, $user)->count(); return $query->count();
} }
/** protected function filterDiscussionVisibleTo($ids, $user)
* Scope a query to only include records that are visible to a user.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param \Flarum\Core\Models\User $user
* @return \Illuminate\Database\Eloquent\Builder
*/
protected function scopeVisibleForUser(Builder $query, User $user = null)
{ {
if ($user !== null) { // For each post ID, we need to make sure that the discussion it's in
$query->whereCan($user, 'view'); // is visible to the user.
if ($user) {
$ids = Discussion::join('posts', 'discussions.id', '=', 'posts.discussion_id')
->whereIn('posts.id', $ids)
->whereVisibleTo($user)
->get(['posts.id'])
->lists('id');
} }
return $query; return $ids;
}
protected function filterVisibleTo($posts, $user)
{
if ($user) {
$posts = $posts->filter(function ($post) use ($user) {
return $post->can($user, 'view');
});
}
return $posts;
} }
} }

View File

@ -29,7 +29,7 @@ class EloquentUserRepository implements UserRepositoryInterface
{ {
$query = User::where('id', $id); $query = User::where('id', $id);
return $this->scopeVisibleForUser($query, $user)->firstOrFail(); return $this->scopeVisibleTo($query, $user)->firstOrFail();
} }
/** /**
@ -67,7 +67,7 @@ class EloquentUserRepository implements UserRepositoryInterface
{ {
$query = User::where('username', 'like', $username); $query = User::where('username', 'like', $username);
return $this->scopeVisibleForUser($query, $user)->pluck('id'); return $this->scopeVisibleTo($query, $user)->pluck('id');
} }
/** /**
@ -85,7 +85,7 @@ class EloquentUserRepository implements UserRepositoryInterface
->orderByRaw('username = ? desc', [$string]) ->orderByRaw('username = ? desc', [$string])
->orderByRaw('username like ? desc', [$string.'%']); ->orderByRaw('username like ? desc', [$string.'%']);
return $this->scopeVisibleForUser($query, $user)->lists('id'); return $this->scopeVisibleTo($query, $user)->lists('id');
} }
/** /**
@ -95,10 +95,10 @@ class EloquentUserRepository implements UserRepositoryInterface
* @param \Flarum\Core\Models\User $user * @param \Flarum\Core\Models\User $user
* @return \Illuminate\Database\Eloquent\Builder * @return \Illuminate\Database\Eloquent\Builder
*/ */
protected function scopeVisibleForUser(Builder $query, User $user = null) protected function scopeVisibleTo(Builder $query, User $user = null)
{ {
if ($user !== null) { if ($user !== null) {
$query->whereCan($user, 'view'); $query->whereVisibleTo($user);
} }
return $query; return $query;

View File

@ -59,7 +59,7 @@ class DiscussionSearcher implements SearcherInterface
public function search(DiscussionSearchCriteria $criteria, $limit = null, $offset = 0, $load = []) public function search(DiscussionSearchCriteria $criteria, $limit = null, $offset = 0, $load = [])
{ {
$this->user = $criteria->user; $this->user = $criteria->user;
$this->query = $this->discussions->query()->whereCan($criteria->user, 'view'); $this->query = $this->discussions->query()->whereVisibleTo($criteria->user);
$this->gambits->apply($criteria->query, $this); $this->gambits->apply($criteria->query, $this);

View File

@ -27,8 +27,8 @@ class PermissionsTableSeeder extends Seeder
// Moderators can edit + delete stuff and suspend users // Moderators can edit + delete stuff and suspend users
[4, 'discussion.delete'], [4, 'discussion.delete'],
[4, 'discussion.rename'], [4, 'discussion.rename'],
[4, 'post.delete'], [4, 'discussion.editPosts'],
[4, 'post.edit'], [4, 'discussion.deletePosts'],
[4, 'user.suspend'], [4, 'user.suspend'],
]; ];

View File

@ -0,0 +1,57 @@
<?php namespace Flarum\Core\Support;
use Flarum\Core\Exceptions\PermissionDeniedException;
use Flarum\Core\Models\User;
use Closure;
trait Locked
{
protected static $conditions = [];
protected static function getConditions($action)
{
$conditions = isset(static::$conditions[$action]) ? static::$conditions[$action] : [];
$all = isset(static::$conditions['*']) ? static::$conditions['*'] : [];
return array_merge($conditions, $all);
}
public static function allow($action, Closure $condition)
{
foreach ((array) $action as $action) {
if (! isset(static::$conditions[$action])) {
static::$conditions[$action] = [];
}
static::$conditions[$action][] = $condition;
}
}
public function can(User $user, $action)
{
foreach ($this->getConditions($action) as $condition) {
$can = $condition($this, $user, $action);
if ($can !== null) {
return $can;
}
}
}
/**
* Assert that the user has a certain permission for this model, throwing
* an exception if they don't.
*
* @param \Flarum\Core\Models\User $user
* @param string $permission
* @return void
*
* @throws \Flarum\Core\Exceptions\PermissionDeniedException
*/
public function assertCan(User $user, $action)
{
if (! $this->can($user, $action)) {
throw new PermissionDeniedException;
}
}
}

View File

@ -0,0 +1,20 @@
<?php namespace Flarum\Core\Support;
use Flarum\Core\Models\User;
trait VisibleScope
{
protected static $visibleScopes = [];
public static function scopeVisible($scope)
{
static::$visibleScopes[] = $scope;
}
public function scopeWhereVisibleTo($query, User $user)
{
foreach (static::$visibleScopes as $scope) {
$scope($query, $user);
}
}
}