diff --git a/src/AccessServiceProvider.php b/src/AccessServiceProvider.php index f9b8d4e..4ef2667 100644 --- a/src/AccessServiceProvider.php +++ b/src/AccessServiceProvider.php @@ -2,10 +2,19 @@ namespace Lomkit\Access; +use Illuminate\Foundation\Events\PublishingStubs; +use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; +use Lomkit\Access\Console\ControlMakeCommand; +use Lomkit\Access\Console\PerimeterMakeCommand; class AccessServiceProvider extends ServiceProvider { + protected array $devCommands = [ + 'ControlMake' => ControlMakeCommand::class, + 'PerimeterMake' => PerimeterMakeCommand::class, + ]; + /** * Registers the service provider. * @@ -13,6 +22,8 @@ class AccessServiceProvider extends ServiceProvider */ public function register() { + $this->registerCommands($this->devCommands); + $this->mergeConfigFrom( __DIR__.'/../config/access-control.php', 'access-control' @@ -27,6 +38,42 @@ public function register() public function boot() { $this->registerPublishing(); + + $this->registerStubs(); + } + + /** + * Register the given commands. + * + * @param array $commands + * + * @return void + */ + protected function registerCommands(array $commands) + { + foreach ($commands as $commandName => $command) { + $method = "register{$commandName}Command"; + + if (method_exists($this, $method)) { + $this->{$method}(); + } else { + $this->app->singleton($command); + } + } + + $this->commands(array_values($commands)); + } + + /** + * Register the stubs on the default laravel stub publish command. + */ + protected function registerStubs() + { + Event::listen(function (PublishingStubs $event) { + $event->add(realpath(__DIR__.'/Console/stubs/control.stub'), 'controller.stub'); + $event->add(realpath(__DIR__.'/Console/stubs/perimeter.plain.stub'), 'perimeter.plain.stub'); + $event->add(realpath(__DIR__.'/Console/stubs/perimeter.overlay.stub'), 'perimeter.overlay.stub'); + }); } /** @@ -42,4 +89,14 @@ private function registerPublishing() ], 'access-control-config'); } } + + /** + * Get the services provided by the provider. + * + * @return array + */ + public function provides() + { + return array_values($this->devCommands); + } } diff --git a/src/Console/ControlMakeCommand.php b/src/Console/ControlMakeCommand.php new file mode 100644 index 0000000..e9d8b7d --- /dev/null +++ b/src/Console/ControlMakeCommand.php @@ -0,0 +1,195 @@ +resolveStubPath('/stubs/control.stub'); + } + + /** + * Resolve the fully-qualified path to the stub. + * + * @param string $stub + * + * @return string + */ + protected function resolveStubPath($stub) + { + return file_exists($customPath = $this->laravel->basePath(trim($stub, '/'))) + ? $customPath + : __DIR__.$stub; + } + + /** + * Get the default namespace for the class. + * + * @param string $rootNamespace + * + * @return string + */ + protected function getDefaultNamespace($rootNamespace) + { + return $rootNamespace.'\Access\Controls'; + } + + /** + * Build the class with the given name. + * + * Remove the base controller import if we are already in the base namespace. + * + * @param string $name + * + * @return string + */ + protected function buildClass($name) + { + $rootNamespace = $this->rootNamespace(); + $controlNamespace = $this->getNamespace($name); + + $replace = []; + + $baseControlExists = file_exists($this->getPath("{$rootNamespace}Access\Controls\Control")); + + $replace = $this->buildPerimetersReplacements($replace, $this->option('perimeters')); + + if ($baseControlExists) { + $replace["use {$controlNamespace}\Control;\n"] = ''; + } else { + $replace[' extends Control'] = ''; + $replace["use {$rootNamespace}Access\Controls\Control;\n"] = ''; + } + + return str_replace( + array_keys($replace), + array_values($replace), + parent::buildClass($name) + ); + } + + /** + * Build the model replacement values. + * + * @param array $replace + * @param array $perimeters + * + * @return array + */ + protected function buildPerimetersReplacements(array $replace, array $perimeters) + { + $perimetersImplementation = ''; + + foreach ($perimeters as $perimeter) { + $perimeterClass = $this->rootNamespace().'Access\\Perimeters\\'.$perimeter; + + $perimetersImplementation .= <<should(function (Model \$user, string \$method, Model \$model) { + return true; + }) + ->allowed(function (Model \$user) { + return true; + }) + ->query(function (Builder \$query, Model \$user) { + return \$query; + }),\\n + PERIMETER; + } + + return array_merge($replace, [ + '{{ perimeters }}' => $perimetersImplementation, + '{{perimeters}}' => $perimetersImplementation, + ]); + } + + /** + * Get the console command options. + * + * @return array + */ + protected function getOptions() + { + return [ + ['perimeters', 'p', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'The perimeters that the control relies on'], + ]; + } + + /** + * Interact further with the user if they were prompted for missing arguments. + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param \Symfony\Component\Console\Output\OutputInterface $output + * + * @return void + */ + protected function afterPromptingForMissingArguments(InputInterface $input, OutputInterface $output) + { + if ($this->didReceiveOptions($input)) { + return; + } + $perimeters = multiselect( + 'What perimeters should this control apply to? (Optional)', + $this->possiblePerimeters(), + ); + + if ($perimeters) { + $input->setOption('perimeters', $perimeters); + } + } + + /** + * Get a list of possible model names. + * + * @return array + */ + protected function possiblePerimeters() + { + $perimetersPath = is_dir(app_path('Access/Perimeters')) ? app_path('Access/Perimeters') : app_path(); + + return (new Collection(Finder::create()->files()->depth(0)->in($perimetersPath))) + ->map(fn ($file) => $file->getBasename('.php')) + ->sort() + ->values() + ->all(); + } +} diff --git a/src/Console/PerimeterMakeCommand.php b/src/Console/PerimeterMakeCommand.php new file mode 100644 index 0000000..9a5b025 --- /dev/null +++ b/src/Console/PerimeterMakeCommand.php @@ -0,0 +1,131 @@ +option('overlay')) { + $stub = '/stubs/perimeter.overlay.stub'; + } + + $stub ??= '/stubs/perimeter.plain.stub'; + + return $this->resolveStubPath($stub); + } + + /** + * Resolve the fully-qualified path to the stub. + * + * @param string $stub + * + * @return string + */ + protected function resolveStubPath($stub) + { + return file_exists($customPath = $this->laravel->basePath(trim($stub, '/'))) + ? $customPath + : __DIR__.$stub; + } + + /** + * Get the default namespace for the class. + * + * @param string $rootNamespace + * + * @return string + */ + protected function getDefaultNamespace($rootNamespace) + { + return $rootNamespace.'\Access\Perimeters'; + } + + /** + * Build the class with the given name. + * + * Remove the base controller import if we are already in the base namespace. + * + * @param string $name + * + * @return string + */ + protected function buildClass($name) + { + $replace = []; + + return str_replace( + array_keys($replace), + array_values($replace), + parent::buildClass($name) + ); + } + + /** + * Get the console command options. + * + * @return array + */ + protected function getOptions() + { + return [ + ['overlay', 'o', InputOption::VALUE_OPTIONAL, 'Indicates if the perimeter overlays'], + ]; + } + + /** + * Interact further with the user if they were prompted for missing arguments. + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param \Symfony\Component\Console\Output\OutputInterface $output + * + * @return void + */ + protected function afterPromptingForMissingArguments(InputInterface $input, OutputInterface $output) + { + if ($this->didReceiveOptions($input)) { + return; + } + + if (confirm('Should this perimeter be an overlay?', false)) { + $input->setOption('overlay', true); + } + } +} diff --git a/src/Console/stubs/control.stub b/src/Console/stubs/control.stub new file mode 100644 index 0000000..ff42a08 --- /dev/null +++ b/src/Console/stubs/control.stub @@ -0,0 +1,22 @@ + An array of Perimeter objects. + */ + protected function perimeters(): array + { + return [{{ perimeters }}]; + } +} diff --git a/src/Console/stubs/perimeter.overlay.stub b/src/Console/stubs/perimeter.overlay.stub new file mode 100644 index 0000000..14470d7 --- /dev/null +++ b/src/Console/stubs/perimeter.overlay.stub @@ -0,0 +1,11 @@ + An array of Perimeter objects. + * @return array<\Lomkit\Access\Perimeters\Perimeter> An array of Perimeter objects. */ protected function perimeters(): array { diff --git a/src/Policies/ControlledPolicy.php b/src/Policies/ControlledPolicy.php index cfd17ed..0e3b0f0 100644 --- a/src/Policies/ControlledPolicy.php +++ b/src/Policies/ControlledPolicy.php @@ -7,7 +7,7 @@ class ControlledPolicy { - //@TODO: what to do for other methods like attach ? It only has view / etc basic methods + //@TODO: what to do for other methods like attach / restore / force_delete ? It only has view / etc basic methods /** * The model class string. @@ -98,4 +98,30 @@ public function delete(Model $user, Model $model) { return $this->getControl()->should($user, __FUNCTION__, $model); } + + /** + * Determines if the specified user is authorized to restore the given model instance. + * + * @param Model $user The user attempting the restoration. + * @param Model $model The model instance to be restored. + * + * @return bool True if restoration is permitted, false otherwise. + */ + public function restore(Model $user, Model $model) + { + return $this->getControl()->should($user, __FUNCTION__, $model); + } + + /** + * Determines if the specified user is authorized to force delete the given model instance. + * + * @param Model $user The user attempting the force deletion. + * @param Model $model The model instance to be force deleted. + * + * @return bool True if force deletion is permitted, false otherwise. + */ + public function forceDelete(Model $user, Model $model) + { + return $this->getControl()->should($user, __FUNCTION__, $model); + } } diff --git a/tests/Feature/ControlsQueryTest.php b/tests/Feature/ControlsQueryTest.php index d0acae0..4edf236 100644 --- a/tests/Feature/ControlsQueryTest.php +++ b/tests/Feature/ControlsQueryTest.php @@ -1,5 +1,7 @@ assertTrue((new \Lomkit\Access\Tests\Support\Access\Controls\ModelControl())->applies(Auth::user(), 'delete', $model)); } - public function test_control_should_not_delete_global_using_shared_overlayed_perimeter(): void + public function test_control_should_delete_using_shared_overlayed_perimeter(): void { Auth::user()->update(['should_shared' => true]); Auth::user()->update(['should_global' => true]); diff --git a/tests/Unit/Console/MakeCommandsTest.php b/tests/Unit/Console/MakeCommandsTest.php new file mode 100644 index 0000000..0d9e9d4 --- /dev/null +++ b/tests/Unit/Console/MakeCommandsTest.php @@ -0,0 +1,97 @@ +artisan('make:perimeter', ['name' => 'TestPerimeter']) + ->assertOk() + ->run(); + + $this->assertFileExists(app_path('Access/Perimeters/TestPerimeter.php')); + $this->assertStringContainsString('class TestPerimeter extends Perimeter', file_get_contents(app_path('Access/Perimeters/TestPerimeter.php'))); + + unlink(app_path('Access/Perimeters/TestPerimeter.php')); + } + + public function test_make_overlay_perimeter_command() + { + @unlink(app_path('Access/Perimeters/TestPerimeter.php')); + + $this + ->artisan('make:perimeter', ['name' => 'TestPerimeter', '--overlay' => true]) + ->assertOk() + ->run(); + + $this->assertFileExists(app_path('Access/Perimeters/TestPerimeter.php')); + $this->assertStringContainsString('class TestPerimeter extends OverlayPerimeter', file_get_contents(app_path('Access/Perimeters/TestPerimeter.php'))); + + unlink(app_path('Access/Perimeters/TestPerimeter.php')); + } + + public function test_make_control_command() + { + @unlink(app_path('Access/Controls/TestControl.php')); + + $this + ->artisan('make:control', ['name' => 'TestControl']) + ->assertOk() + ->run(); + + $this->assertFileExists(app_path('Access/Controls/TestControl.php')); + $this->assertStringContainsString('class TestControl', file_get_contents(app_path('Access/Controls/TestControl.php'))); + + unlink(app_path('Access/Controls/TestControl.php')); + } + + public function test_make_control_with_base_control_command() + { + @unlink(app_path('Access/Controls/TestControl.php')); + @unlink(app_path('Access/Controls/Control.php')); + + file_put_contents(app_path('Access/Controls/Control.php'), ''); + + $this + ->artisan('make:control', ['name' => 'TestControl']) + ->assertOk() + ->run(); + + $this->assertFileExists(app_path('Access/Controls/TestControl.php')); + $this->assertStringContainsString('class TestControl extends Control', file_get_contents(app_path('Access/Controls/TestControl.php'))); + + unlink(app_path('Access/Controls/TestControl.php')); + unlink(app_path('Access/Controls/Control.php')); + } + + public function test_make_control_with_perimeters_command() + { + @unlink(app_path('Access/Controls/TestControl.php')); + @unlink(app_path('Access/Perimeters/TestPerimeter.php')); + @unlink(app_path('Access/Perimeters/SecondTestPerimeter.php')); + + file_put_contents(app_path('Access/Perimeters/TestPerimeter.php'), ''); + file_put_contents(app_path('Access/Perimeters/SecondTestPerimeter.php'), ''); + + $this + ->artisan('make:control') + ->expectsQuestion('What should the control be named?', 'TestControl') + ->expectsChoice('What perimeters should this control apply to? (Optional)', ['TestPerimeter'], ['TestPerimeter', 'SecondTestPerimeter']) + ->assertOk() + ->run(); + + $this->assertFileExists(app_path('Access/Controls/TestControl.php')); + $this->assertStringContainsString('class TestControl', file_get_contents(app_path('Access/Controls/TestControl.php'))); + $this->assertStringContainsString('App\\Access\\Perimeters\\TestPerimeter::new()', file_get_contents(app_path('Access/Controls/TestControl.php'))); + + unlink(app_path('Access/Perimeters/TestPerimeter.php')); + unlink(app_path('Access/Perimeters/SecondTestPerimeter.php')); + unlink(app_path('Access/Controls/TestControl.php')); + } +} diff --git a/tests/Unit/StubsTest.php b/tests/Unit/StubsTest.php new file mode 100644 index 0000000..7d9b40b --- /dev/null +++ b/tests/Unit/StubsTest.php @@ -0,0 +1,22 @@ +dispatch($event = new \Illuminate\Foundation\Events\PublishingStubs([])); + + $this->assertEquals( + [ + realpath(__DIR__.'/../../src/Console/stubs/control.stub') => 'controller.stub', + realpath(__DIR__.'/../../src/Console/stubs/perimeter.plain.stub') => 'perimeter.plain.stub', + realpath(__DIR__.'/../../src/Console/stubs/perimeter.overlay.stub') => 'perimeter.overlay.stub', + ], + $event->stubs + ); + } +} diff --git a/tests/Unit/TestCase.php b/tests/Unit/TestCase.php index 977e9e9..861fad7 100644 --- a/tests/Unit/TestCase.php +++ b/tests/Unit/TestCase.php @@ -1,5 +1,7 @@