fix(permissions): resolve module policy paths via composer PSR-4 prefixes

Modules do not share a single layout: some map their namespace to an
app/ subdirectory while others fall back to the Modules\ => modules
root. The previous modules/<class> hardcode dropped the app/ segment and
wrote policies to the module root. Resolve the file path through the
registered Composer PSR-4 prefixes, honouring the longest match.
This commit is contained in:
Arthur Parienté 2026-07-08 21:31:58 +02:00
parent 0827b15345
commit 49d348aed8
No known key found for this signature in database
2 changed files with 65 additions and 1 deletions

View File

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Services\PermissionRegistry; use App\Services\PermissionRegistry;
use Composer\Autoload\ClassLoader;
use Illuminate\Console\Attributes\Description; use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature; use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command; use Illuminate\Console\Command;
@ -72,12 +73,45 @@ class GeneratePolicies extends Command
} }
if (str_starts_with($class, 'Modules\\')) { if (str_starts_with($class, 'Modules\\')) {
return base_path('modules/'.str_replace('\\', '/', Str::after($class, 'Modules\\')).'.php'); return $this->modulePathForClass($class);
} }
return null; return null;
} }
/**
* Resolve a module class to its file path via the registered Composer
* PSR-4 prefixes, honouring the most specific (longest) match. Modules do
* not share a single layout: some map Modules\Foo\ to modules/Foo/app,
* while others fall back to the Modules\ => modules root. Hardcoding
* modules/<class> would drop the app/ segment for the former and write the
* policy to the module root instead of app/Policies.
*/
protected function modulePathForClass(string $class): ?string
{
/** @var ClassLoader $loader */
$loader = require base_path('vendor/autoload.php');
$bestPrefix = null;
$bestDir = null;
foreach ($loader->getPrefixesPsr4() as $prefix => $directories) {
if (str_starts_with($class, $prefix) && ($bestPrefix === null || strlen($prefix) > strlen($bestPrefix))) {
$bestPrefix = $prefix;
$bestDir = $directories[0];
}
}
if ($bestPrefix === null || $bestDir === null) {
return null;
}
$dir = realpath($bestDir) ?: $bestDir;
$relative = str_replace('\\', '/', Str::after($class, $bestPrefix));
return rtrim($dir, '/').'/'.$relative.'.php';
}
protected function stub(string $policyClass, string $subject): string protected function stub(string $policyClass, string $subject): string
{ {
$namespace = Str::beforeLast($policyClass, '\\'); $namespace = Str::beforeLast($policyClass, '\\');

View File

@ -2,8 +2,21 @@
declare(strict_types=1); declare(strict_types=1);
use App\Console\Commands\GeneratePolicies;
use Composer\Autoload\ClassLoader;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
/**
* Invoke the protected modulePathForClass resolver on a fresh command.
*/
function resolveModulePath(string $class): ?string
{
$command = new GeneratePolicies();
$method = (new ReflectionMethod($command, 'modulePathForClass'));
return $method->invoke($command, $class);
}
it('generates a thin policy for a resource model that lacks one', function (): void { it('generates a thin policy for a resource model that lacks one', function (): void {
$path = app_path('Policies/Filament/FlightBundlePolicy.php'); $path = app_path('Policies/Filament/FlightBundlePolicy.php');
$original = File::exists($path) ? File::get($path) : null; $original = File::exists($path) ? File::get($path) : null;
@ -40,3 +53,20 @@ it('regenerates a valid stub with --force', function (): void {
expect($content)->toContain('extends BasePolicy'); expect($content)->toContain('extends BasePolicy');
expect($content)->toContain("\$subject = 'award'"); expect($content)->toContain("\$subject = 'award'");
}); });
it('resolves a module policy path via the most specific PSR-4 prefix', function (): void {
/** @var ClassLoader $loader */
$loader = require base_path('vendor/autoload.php');
// A module that maps its namespace to an app/ subdirectory must keep the
// app/ segment; the generic Modules\ => modules fallback must not win.
$loader->addPsr4('Modules\\Foo\\', base_path('modules/Foo/app'));
try {
$path = resolveModulePath('Modules\\Foo\\Policies\\Filament\\BarPolicy');
expect($path)->toEndWith('modules/Foo/app/Policies/Filament/BarPolicy.php');
} finally {
$loader->setPsr4('Modules\\Foo\\', []);
}
});