JosephSilber / bouncer

Laravel Eloquent roles and abilities.
MIT License
3.46k stars 332 forks source link

Get Model Where User Has PermissionX #622

Open SMPEJake opened 1 year ago

SMPEJake commented 1 year ago

Hello!

I am probably missing something simple here but is there a way i can use a users role or ability as a where clause on an eloquent query?

Thanks!

lrljoe commented 1 year ago

Couple of relatively easy options, depends on how you're using this.

If you're using a mix of roles and direct abilities, then use getAbilities() and filter on the model you're wanting to look at. Then add the role's abilities in.

You can either trust it or filter it further through an each() loop.

Or if you're using scopes then that's a little easier.

Or if you're using the Owns approach then you should already have the approach for that. May be the easiest solution for you?

sneakylenny commented 1 year ago

I do something similar to this in a scope:

// Example to filter entities I'm allowed to "view":
$user = Auth::user(); // Currently logged in user (could be any user with roles and permissions).
$abilities = $user->getAbilities() // Get all the abilities (including the ones inherited from roles).
                ->where('entity_type', get_class(Entity::class)) // Filter permissions that concern this model.
                ->where('name', 'view') // Filter specific permission.
                ->pluck('id'); // Get all ID's of the resulting entities.

Entity::whereIn('id', $abilities)->get(); // Expected output: Enities where user has permissions to "view".

For anyone interested here's my scope:

My scope method --- Run `php artisan make:scope PermissionScope` and add this to it: ```php whereNotIn('id', Auth::user()->getForbiddenAbilities()->where('entity_type', get_class($model))->pluck('entity_id')); // Filter explicit allowed items. $allowed = Auth::user()->getAbilities()->where('entity_type', get_class($model)); if ($allowed->whereNotNull('entity_id')->isNotEmpty() && $allowed->where('name', '*')->whereNull('entity_id')->isEmpty()) { $builder->whereIn('id', $allowed->pluck('entity_id')); } } } } ``` What this scope does: - Filters out forbidden records - Filters out records when user is allowed to "viewAny" but only "view" specific records I then put it in a trait: 1. Create a file `app\Concerns\FilterAllowed.php` 2. Add this to the file: ```php
EriBloo commented 1 year ago

I have something similiar to @timyourivh in my app:

/**
 * Class PermissionConstrained
 *
 * Implements the Scope interface to apply permission constraints to a given Eloquent query builder.
 */
class PermissionConstrained implements Scope
{
    /**
     * Apply the scope to a given Eloquent query builder.
     *
     * @param  Builder  $builder The Eloquent query builder instance.
     * @param  Model&IsPermissionConstrained  $model The model instance to which the scope is applied.
     */
    public function apply(Builder $builder, Model $model): void
    {
        // If checkPermissions is not true or the user is a guest,
        // return without applying the scope.
        if (! $model::$checkPermissions || auth()->guest()) {
            return;
        }

        // Get the permissions that the authenticated user is forbidden from viewing.
        $forbidden = $this->getPermissions($model, false);

        // If the user is forbidden from viewing all records of this model,
        // return an empty result set.
        if ($forbidden->contains(fn (Ability $ability) => $ability->entity_id === null)) {
            $builder->where($model->getKeyName(), null);

            return;
        }

        // Get the permissions that the authenticated user is allowed to view.
        $allowed = $this->getPermissions($model);

        // If the user is allowed to view all records of this model, return without applying the scope.
        if ($allowed->contains(fn (Ability $ability) => $ability->entity_id === null)) {
            return;
        }

        // Apply the scope to the query builder.
        $builder->whereIn("{$model->getTable()}.{$model->getKeyName()}", $allowed->pluck('entity_id')->toArray());
        $builder->whereNotIn("{$model->getTable()}.{$model->getKeyName()}", $forbidden->pluck('entity_id')->toArray());
    }

    /**
     * Extend the query builder with the scope.
     *
     * @param  Builder  $builder The query builder instance.
     */
    public function extend(Builder $builder): void
    {
        $builder->macro('ignorePermissions', function (Builder $builder): Builder {
            return $builder->withoutGlobalScope($this);
        });
    }

    /**
     * Get the permissions for the given model and allowed flag.
     *
     * @param  Model  $model The model instance for which to get the permissions.
     * @param  bool  $allowed The flag indicating whether to get allowed or forbidden permissions.
     * @return Collection The collection of permissions.
     */
    private function getPermissions(Model $model, bool $allowed = true): Collection
    {
        return Cache::driver('array')->sear(
            $this->getCacheKey($model, $allowed),
            function () use ($model, $allowed) {
                return auth()->user()?->{$allowed ? 'getAbilities' : 'getForbiddenAbilities'}()
                    ->whereIn('name', ['*', 'view'])
                    ->whereIn('entity_type', ['*', $model::class]);
            }
        );
    }

    /**
     * Get the cache key for the given model and allowed flag.
     *
     * @param  Model  $model The model instance for which to get the cache key.
     * @param  bool  $allowed The flag indicating whether to include the allowed or forbidden permissions in the cache key.
     * @return string The cache key for the model and allowed flag.
     */
    private function getCacheKey(Model $model, bool $allowed = true): string
    {
        return implode(':', ['permissions', $model->getMorphClass(), $allowed ? 'a' : 'f']);
    }

and trait:

trait IsPermissionConstrained
{
    public static bool $checkPermissions = true;

    public static function bootIsPermissionConstrained(): void
    {
        self::addGlobalScope(new PermissionConstrained);
    }

    public static function withoutPermissions(Closure $closure): void
    {
        self::$checkPermissions = false;
        $closure();
        self::$checkPermissions = true;
    }
}

I have 2 ways to disable permissions. First is ignorePermissions() method - this will disable the scope only for current query. The other is $checkPermissions static variable. The idea is that I have this trait connected to base model that all other models extend from, and when I set Model::checkPermissions = false all queries (including relations) will ignore permissions. There is also helper withoutPermissions method to execute queries closure ignoring permissions.

Scope caches all permissions in array driver - so only for current request (might not work with Octane). This speeds up abilities retrieval greatly with many permissions.