diff --git a/composer.json b/composer.json index 90fd26b..cd96aa1 100644 --- a/composer.json +++ b/composer.json @@ -16,6 +16,7 @@ }, "require-dev": { "guzzlehttp/guzzle": "^6.0|^7.0", + "laravel/scout": "^10", "orchestra/testbench": "^9|^10", "phpunit/phpunit": "^11.0" }, diff --git a/src/AccessServiceProvider.php b/src/AccessServiceProvider.php index 4ef2667..6001f59 100644 --- a/src/AccessServiceProvider.php +++ b/src/AccessServiceProvider.php @@ -3,8 +3,10 @@ namespace Lomkit\Access; use Illuminate\Foundation\Events\PublishingStubs; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; +use Laravel\Scout\Builder; use Lomkit\Access\Console\ControlMakeCommand; use Lomkit\Access\Console\PerimeterMakeCommand; @@ -32,14 +34,28 @@ public function register() /** * Bootstrap any package services. - * - * @return void */ public function boot() { $this->registerPublishing(); $this->registerStubs(); + + $this->bootScoutBuilder(); + } + + /** + * Registers a macro on Laravel Scout's Builder. + */ + protected function bootScoutBuilder(): void + { + if (class_exists(Builder::class)) { + Builder::macro('controlled', function (Builder $builder) { + $control = $builder->model->newControl(); + + return $control->queried($builder, Auth::user()); + }); + } } /** diff --git a/src/Controls/Control.php b/src/Controls/Control.php index 8ed11b8..8a869fc 100644 --- a/src/Controls/Control.php +++ b/src/Controls/Control.php @@ -12,7 +12,7 @@ class Control { - // @TODO: scout queried + // @TODO: change readme image /** * The control name resolver. * @@ -68,12 +68,12 @@ public function applies(Model $user, string $method, Model $model): bool } /** - * Modifies the query builder to enforce access control restrictions for a given user. + * Applies access control restrictions to an Eloquent query builder for the specified user. * - * @param Builder $query The query builder instance to modify. - * @param Model $user The user model used to determine applicable query control restrictions. + * @param Builder $query The Eloquent query builder to modify. + * @param Model $user The user for whom access control is enforced. * - * @return Builder The modified query builder with access controls applied. + * @return Builder The query builder with access control restrictions applied. */ public function queried(Builder $query, Model $user): Builder { @@ -91,12 +91,25 @@ public function queried(Builder $query, Model $user): Builder } /** - * Applies query modifications based on access control perimeters for the given user. + * Applies access control restrictions to a Laravel Scout query builder for the specified user. * - * @param Builder $query The query builder instance to be modified. - * @param Model $user The user model used to evaluate access control conditions. + * @param \Laravel\Scout\Builder $query The Scout query builder to modify. + * @param Model $user The user for whom access control is enforced. * - * @return Builder The query builder after applying access control modifications. + * @return \Laravel\Scout\Builder The query builder with access controls applied. + */ + public function scoutQueried(\Laravel\Scout\Builder $query, Model $user): \Laravel\Scout\Builder + { + return $this->applyScoutQueryControl($query, $user); + } + + /** + * Modifies an Eloquent query builder to enforce access control rules for the specified user. + * + * @param Builder $query The Eloquent query builder to modify. + * @param Model $user The user for whom access control is evaluated. + * + * @return Builder The modified query builder reflecting access control restrictions. */ protected function applyQueryControl(Builder $query, Model $user): Builder { @@ -120,17 +133,58 @@ protected function applyQueryControl(Builder $query, Model $user): Builder } /** - * Modifies the query builder to return no results. + * Applies access control modifications to a Laravel Scout query builder based on defined perimeters. * - * @param Builder $query The query builder instance to modify. + * @param \Laravel\Scout\Builder $query The Scout query builder to modify. + * @param Model $user The user for whom access control is being enforced. * - * @return Builder The modified query builder that yields an empty result set. + * @return \Laravel\Scout\Builder The modified Scout query builder reflecting access control restrictions. + */ + protected function applyScoutQueryControl(\Laravel\Scout\Builder $query, Model $user): \Laravel\Scout\Builder + { + $noResultCallback = function (\Laravel\Scout\Builder $query) { + return $this->noResultScoutQuery($query); + }; + + foreach ($this->perimeters() as $perimeter) { + if ($perimeter->applyAllowedCallback($user)) { + $query = $perimeter->applyScoutQueryCallback($query, $user); + + $noResultCallback = function ($query) {return $query; }; + + if (!$perimeter->overlays()) { + return $query; + } + } + } + + return $noResultCallback($query); + } + + /** + * Alters the Eloquent query builder to ensure no records are returned. + * + * @param Builder $query The Eloquent query builder to modify. + * + * @return Builder The query builder configured to yield no results. */ protected function noResultQuery(Builder $query): Builder { return $query->whereRaw('0=1'); } + /** + * Modifies the Scout query builder to ensure no records are returned. + * + * @param \Laravel\Scout\Builder $query The Scout query builder to modify. + * + * @return \Laravel\Scout\Builder The modified query builder that yields no results. + */ + protected function noResultScoutQuery(\Laravel\Scout\Builder $query): \Laravel\Scout\Builder + { + return $query->where('__NOT_A_VALID_FIELD__', 0); + } + /** * Specify the callback that should be invoked to guess control names. * diff --git a/src/Perimeters/Perimeter.php b/src/Perimeters/Perimeter.php index bf03795..7461106 100644 --- a/src/Perimeters/Perimeter.php +++ b/src/Perimeters/Perimeter.php @@ -8,32 +8,51 @@ class Perimeter { + protected Closure $scoutQueryCallback; + protected Closure $queryCallback; protected Closure $shouldCallback; protected Closure $allowedCallback; + /** + * Initializes the Perimeter with default callbacks for access control and query customization. + */ public function __construct() { // Default implementations that can be overridden + $this->scoutQueryCallback = function (\Laravel\Scout\Builder $query, Model $user) { return $query; }; $this->queryCallback = function (Builder $query, Model $user) { return $query; }; $this->shouldCallback = function (Model $user, string $method, Model $model) { return true; }; $this->allowedCallback = function (Model $user) { return true; }; } /** - * Executes the should callback to determine if the access control condition is met. + * Determines if the access control condition should be applied for the given user, method, and model. * - * @param Model $user The user instance for which the check is performed. - * @param string $method The access control method or action being evaluated. - * @param Model $model The model instance related to the access check. + * @param Model $user The user being evaluated. + * @param string $method The access control action or method. + * @param Model $model The related model instance. * - * @return bool True if the callback validation passes; otherwise, false. + * @return bool True if the condition applies; false otherwise. */ public function applyShouldCallback(Model $user, string $method, Model $model): bool { return ($this->shouldCallback)($user, $method, $model); } + /** + * Applies the configured Scout query callback to modify a Laravel Scout search query for a given user. + * + * @param \Laravel\Scout\Builder $query The Scout query builder to modify. + * @param Model $user The user model for whom the query is being modified. + * + * @return \Laravel\Scout\Builder The modified Scout query builder. + */ + public function applyScoutQueryCallback(\Laravel\Scout\Builder $query, Model $user): \Laravel\Scout\Builder + { + return ($this->scoutQueryCallback)($query, $user); + } + /** * Applies the registered query callback to modify the query builder based on the user's context. * @@ -88,11 +107,11 @@ public function should(Closure $shouldCallback): self } /** - * Sets the query modification callback. + * Sets a custom callback to modify Eloquent query builders for access control. * - * @param Closure $queryCallback A callback that customizes the query logic. + * @param Closure $queryCallback Callback that receives and returns a query builder. * - * @return self Returns the current instance for method chaining. + * @return self The current Perimeter instance. */ public function query(Closure $queryCallback): self { @@ -101,6 +120,20 @@ public function query(Closure $queryCallback): self return $this; } + /** + * Sets the callback used to modify Laravel Scout search queries for this perimeter. + * + * @param Closure $scoutQueryCallback Callback that receives a Scout query builder and user model, and returns a modified query builder. + * + * @return self + */ + public function scoutQuery(Closure $scoutQueryCallback): self + { + $this->scoutQueryCallback = $scoutQueryCallback; + + return $this; + } + /** * Creates and returns a new instance of the Perimeter class. * diff --git a/tests/Feature/ControlsScoutQueryTest.php b/tests/Feature/ControlsScoutQueryTest.php new file mode 100644 index 0000000..0aa3540 --- /dev/null +++ b/tests/Feature/ControlsScoutQueryTest.php @@ -0,0 +1,107 @@ +count(50) + ->create(); + + $query = Model::search(); + $query = (new \Lomkit\Access\Tests\Support\Access\Controls\ModelControl())->scoutQueried($query, Auth::user()); + + $this->assertEquals(['__NOT_A_VALID_FIELD__' => 0], $query->wheres); + } + + public function test_control_scout_queried_using_client_perimeter(): void + { + Auth::user()->update(['should_client' => true]); + + Model::factory() + ->count(50) + ->create(); + Model::factory() + ->state(['is_client' => true]) + ->count(50) + ->create(); + + $query = Model::search(); + $query = (new \Lomkit\Access\Tests\Support\Access\Controls\ModelControl())->scoutQueried($query, Auth::user()); + + $this->assertEquals(['is_client' => true], $query->wheres); + } + + public function test_control_scout_queried_using_shared_overlayed_perimeter(): void + { + Auth::user()->update(['should_shared' => true]); + Auth::user()->update(['should_client' => true]); + + Model::factory() + ->count(50) + ->create(); + Model::factory() + ->state(['is_shared' => true]) + ->count(50) + ->create(); + Model::factory() + ->state(['is_client' => true]) + ->count(50) + ->create(); + + $query = Model::search(); + $query = (new \Lomkit\Access\Tests\Support\Access\Controls\ModelControl())->scoutQueried($query, Auth::user()); + + $this->assertEquals(['is_shared' => true, 'is_client' => true], $query->wheres); + } + + public function test_control_scout_queried_using_shared_overlayed_perimeter_with_distant_perimeter(): void + { + Auth::user()->update(['should_shared' => true]); + Auth::user()->update(['should_own' => true]); + + Model::factory() + ->state(['is_client' => true]) + ->count(50) + ->create(); + Model::factory() + ->state(['is_shared' => true]) + ->count(50) + ->create(); + Model::factory() + ->state(['is_own' => true]) + ->count(50) + ->create(); + + $query = Model::search(); + $query = (new \Lomkit\Access\Tests\Support\Access\Controls\ModelControl())->scoutQueried($query, Auth::user()); + + $this->assertEquals(['is_shared' => true, 'is_own' => true], $query->wheres); + } + + public function test_control_scout_queried_using_only_shared_overlayed_perimeter(): void + { + Auth::user()->update(['should_shared' => true]); + + Model::factory() + ->state(['is_client' => true]) + ->count(50) + ->create(); + Model::factory() + ->state(['is_shared' => true]) + ->count(50) + ->create(); + Model::factory() + ->state(['is_own' => true]) + ->count(50) + ->create(); + + $query = Model::search(); + $query = (new \Lomkit\Access\Tests\Support\Access\Controls\ModelControl())->scoutQueried($query, Auth::user()); + + $this->assertEquals(['is_shared' => true], $query->wheres); + } +} diff --git a/tests/Support/Access/Controls/ModelControl.php b/tests/Support/Access/Controls/ModelControl.php index fc3add1..56da960 100644 --- a/tests/Support/Access/Controls/ModelControl.php +++ b/tests/Support/Access/Controls/ModelControl.php @@ -14,7 +14,6 @@ class ModelControl extends Control { protected function perimeters(): array { - // @TODO: possible to extract the should callback to another method ?? $shouldCallback = function (Model $user, string $method, Model $model) { return in_array($method, explode(',', $model->allowed_methods)); }; @@ -27,6 +26,9 @@ protected function perimeters(): array ->should(function (Model $user, string $method, Model $model) { return in_array($method.'_shared', explode(',', $model->allowed_methods)); }) + ->scoutQuery(function (\Laravel\Scout\Builder $query, Model $user) { + return $query->where('is_shared', true); + }) ->query(function (Builder $query, Model $user) { return $query->orWhere('is_shared', true); }), @@ -35,6 +37,9 @@ protected function perimeters(): array return $user->should_global; }) ->should($shouldCallback) + ->scoutQuery(function (\Laravel\Scout\Builder $query, Model $user) { + return $query->where('is_global', true); + }) ->query(function (Builder $query, Model $user) { return $query->orWhere('is_global', true); }), @@ -43,6 +48,9 @@ protected function perimeters(): array return $user->should_client; }) ->should($shouldCallback) + ->scoutQuery(function (\Laravel\Scout\Builder $query, Model $user) { + return $query->where('is_client', true); + }) ->query(function (Builder $query, Model $user) { return $query->orWhere('is_client', true); }), @@ -51,6 +59,9 @@ protected function perimeters(): array return $user->should_own; }) ->should($shouldCallback) + ->scoutQuery(function (\Laravel\Scout\Builder $query, Model $user) { + return $query->where('is_own', true); + }) ->query(function (Builder $query, Model $user) { return $query->orWhere('is_own', true); }), diff --git a/tests/Support/Models/Model.php b/tests/Support/Models/Model.php index 7b1d43c..3d8e05f 100644 --- a/tests/Support/Models/Model.php +++ b/tests/Support/Models/Model.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model as BaseModel; +use Laravel\Scout\Searchable; use Lomkit\Access\Controls\HasControl; use Lomkit\Access\Tests\Support\Database\Factories\ModelFactory; @@ -11,6 +12,7 @@ class Model extends BaseModel { use HasFactory; use HasControl; + use Searchable; protected static function newFactory() {