Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions src/AccessServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,28 @@

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.
*
* @return void
*/
public function register()
{
$this->registerCommands($this->devCommands);

$this->mergeConfigFrom(
__DIR__.'/../config/access-control.php',
'access-control'
Expand All @@ -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');
});
}

/**
Expand All @@ -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);
}
}
195 changes: 195 additions & 0 deletions src/Console/ControlMakeCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
<?php

namespace Lomkit\Access\Console;

use Illuminate\Console\GeneratorCommand;
use Illuminate\Support\Collection;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Finder\Finder;

use function Laravel\Prompts\multiselect;

#[AsCommand(name: 'make:control')]
class ControlMakeCommand extends GeneratorCommand
{
/**
* The console command name.
*
* @var string
*/
protected $name = 'make:control';

/**
* The console command description.
*
* @var string
*/
protected $description = 'Create a new control class';

/**
* The type of class being generated.
*
* @var string
*/
protected $type = 'Control';

/**
* Get the stub file for the generator.
*
* @return string
*/
protected function getStub()
{
return $this->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 .= <<<PERIMETER
\\n
$perimeterClass::new()
->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<int, string>
*/
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();
}
Comment on lines +185 to +194
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Limited approach to finding perimeters.

The possiblePerimeters method only searches for perimeters in a fixed location, which may not work for all project structures.

Consider a more flexible approach:

protected function possiblePerimeters()
{
-   $perimetersPath = is_dir(app_path('Access/Perimeters')) ? app_path('Access/Perimeters') : app_path();
+   // Try multiple potential locations
+   $rootNamespace = $this->rootNamespace();
+   $possiblePaths = [
+       app_path('Access/Perimeters'),
+       app_path('Access'),
+       base_path('src/Access/Perimeters'),
+       base_path('src/Access'),
+   ];
+
+   $perimetersPath = app_path();
+   foreach ($possiblePaths as $path) {
+       if (is_dir($path)) {
+           $perimetersPath = $path;
+           break;
+       }
+   }

    return (new Collection(Finder::create()->files()->depth(0)->in($perimetersPath)))
        ->map(fn ($file) => $file->getBasename('.php'))
+       ->filter(function ($filename) use ($perimetersPath) {
+           // Only include files that are likely to be perimeter classes
+           $content = file_get_contents($perimetersPath.'/'.$filename.'.php');
+           return strpos($content, 'Perimeter') !== false;
+       })
        ->sort()
        ->values()
        ->all();
}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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();
}
protected function possiblePerimeters()
{
// Try multiple potential locations
$rootNamespace = $this->rootNamespace();
$possiblePaths = [
app_path('Access/Perimeters'),
app_path('Access'),
base_path('src/Access/Perimeters'),
base_path('src/Access'),
];
$perimetersPath = app_path();
foreach ($possiblePaths as $path) {
if (is_dir($path)) {
$perimetersPath = $path;
break;
}
}
return (new Collection(Finder::create()->files()->depth(0)->in($perimetersPath)))
->map(fn ($file) => $file->getBasename('.php'))
->filter(function ($filename) use ($perimetersPath) {
// Only include files that are likely to be perimeter classes
$content = file_get_contents($perimetersPath.'/'.$filename.'.php');
return strpos($content, 'Perimeter') !== false;
})
->sort()
->values()
->all();
}

}
Loading