<?php
 namespace App\Console\Commands; use Illuminate\Console\Command; use Illuminate\Routing\Route as RoutingRoute; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; class PermissionsCheckDriftCommand extends Command { protected $signature = 'permissions:check-drift {--json : Emit machine-readable JSON instead of a table}'; protected $description = 'Audit permission key drift between routes, seeders, and the DB'; public function handle(): int { $fromRoutes = $this->collectRouteKeys(); $fromSeeders = $this->collectSeederKeys(); $fromDb = $this->collectDbKeys(); $allDefined = $fromSeeders->merge($fromDb)->unique()->values(); $missing = $fromRoutes->reject(fn ($k) => $allDefined->contains($k))->values(); $orphan = $allDefined->reject(fn ($k) => $fromRoutes->contains($k))->values(); $seederDrift = $fromDb->reject(fn ($k) => $fromSeeders->contains($k))->values(); if ($this->option('json')) { $this->line(json_encode([ 'missing' => $missing->all(), 'orphan' => $orphan->all(), 'seeder_drift' => $seederDrift->all(), 'counts' => [ 'routes' => $fromRoutes->count(), 'seeders' => $fromSeeders->count(), 'db' => $fromDb->count(), ], ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); return self::SUCCESS; } $this->info("Permission audit results:"); $this->line("  routes  references : {$fromRoutes->count()}"); $this->line("  seeders definitions: {$fromSeeders->count()}"); $this->line("  database rows      : {$fromDb->count()}"); $this->newLine(); if ($missing->isNotEmpty()) { $this->error("MISSING — referenced in routes, not defined in seeders/DB ({$missing->count()}):"); foreach ($missing as $k) { $this->line("  - {$k}"); } $this->newLine(); } if ($orphan->isNotEmpty()) { $this->warn("ORPHAN — defined in seeders/DB, not used by any route ({$orphan->count()}):"); foreach ($orphan as $k) { $this->line("  - {$k}"); } $this->newLine(); } if ($seederDrift->isNotEmpty()) { $this->comment("INFO — present in DB but missing from PermissionSeeder/AdminSeeder ({$seederDrift->count()}):"); foreach ($seederDrift as $k) { $this->line("  - {$k}"); } $this->newLine(); } if ($missing->isEmpty() && $orphan->isEmpty() && $seederDrift->isEmpty()) { $this->info('No drift detected. Routes, seeders, and DB agree.'); } return $missing->isEmpty() ? self::SUCCESS : self::FAILURE; } private function collectRouteKeys(): \Illuminate\Support\Collection { $keys = collect(); $routes = Route::getRoutes(); foreach ($routes as $route) { foreach ($route->gatherMiddleware() as $mw) { if (! is_string($mw)) { continue; } if (Str::startsWith($mw, 'can:')) { $keys->push(trim(Str::after($mw, 'can:'))); continue; } if (Str::startsWith($mw, 'permission:')) { foreach (explode('|', trim(Str::after($mw, 'permission:'))) as $k) { $keys->push(trim($k)); } } } } return $keys->filter()->unique()->sort()->values(); } private function collectSeederKeys(): \Illuminate\Support\Collection { $files = [ database_path('seeders/PermissionSeeder.php'), database_path('seeders/AdminSeeder.php'), ]; $keys = collect(); foreach ($files as $f) { if (! File::exists($f)) { continue; } $contents = File::get($f); if (preg_match_all("/'name'\s*=>\s*'([a-z][^']*)'/", $contents, $m)) { foreach ($m[1] as $k) { $keys->push($k); } } if (preg_match_all("/^\s*'([a-z][a-z0-9 _\-]+)'\s*,\s*$/m", $contents, $m)) { foreach ($m[1] as $k) { if (str_contains($k, ' ')) { $keys->push($k); } } } } $blacklist = [ 'admin', 'web', 'super-admin', 'user', 'ai-staff', 'client-manager', 'client-viewer', ]; return $keys ->filter(fn ($k) => ! in_array($k, $blacklist, true)) ->unique() ->sort() ->values(); } private function collectDbKeys(): \Illuminate\Support\Collection { if (! Schema::hasTable('permissions')) { return collect(); } return DB::table('permissions') ->where('guard_name', 'admin') ->pluck('name') ->unique() ->sort() ->values(); } } 