Files
lotteryLaravel/app/Console/Commands/AuditAdminAuthorizationCommand.php
kang 1d31f9e872 feat(admin): 更新后台权限管理与同步逻辑,简化权限检查并优化文档
- 新增后台 RBAC 相关文档,提供权限目录与维护命令说明。
- 移除不必要的角色资源同步检查,简化权限审计命令。
- 更新权限描述与同步逻辑,确保一致性与可维护性。
- 统一权限注册表,替换过时的权限别名,增强代码可读性。
2026-05-22 16:11:48 +08:00

157 lines
5.2 KiB
PHP

<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Route;
use Illuminate\Routing\Route as IlluminateRoute;
final class AuditAdminAuthorizationCommand extends Command
{
protected $signature = 'lottery:admin-auth-audit
{--skip-route-coverage : 跳过受保护后台路由是否已注册 API 资源的检查}
{--skip-resource-bindings : 跳过 permission_required 资源是否绑定动作权限的检查}';
protected $description = '检查后台权限配置是否存在路由覆盖缺失或资源绑定缺失';
public function handle(): int
{
$issues = [];
if (! (bool) $this->option('skip-route-coverage')) {
$issues = array_merge($issues, $this->checkProtectedAdminRouteCoverage());
}
if (! (bool) $this->option('skip-resource-bindings')) {
$issues = array_merge($issues, $this->checkPermissionResourceBindings());
}
if ($issues === []) {
$this->info('Admin authorization audit passed.');
return self::SUCCESS;
}
$this->error(sprintf('Admin authorization audit found %d issue(s).', count($issues)));
foreach ($issues as $issue) {
$this->line(sprintf('- [%s] %s', $issue['type'], $issue['message']));
}
return self::FAILURE;
}
/**
* @return list<array{type: string, message: string}>
*/
private function checkProtectedAdminRouteCoverage(): array
{
$protectedRoutes = $this->protectedAdminRoutes();
if ($protectedRoutes === []) {
return [[
'type' => 'route_coverage',
'message' => 'No protected admin routes were discovered from the current route collection.',
]];
}
$resourceRouteNames = DB::table('admin_api_resources')
->where('status', 1)
->pluck('route_name')
->filter(static fn ($routeName): bool => is_string($routeName) && $routeName !== '')
->map(fn (string $routeName): string => $this->normalizeAdminRouteName($routeName))
->all();
$resourceRouteNameSet = array_fill_keys($resourceRouteNames, true);
$issues = [];
foreach ($protectedRoutes as $route) {
if (! isset($resourceRouteNameSet[$route['name']])) {
$issues[] = [
'type' => 'route_coverage',
'message' => sprintf(
'Protected route `%s %s` (`%s`) has no active row in `admin_api_resources`.',
$route['method'],
$route['uri'],
$route['name'],
),
];
}
}
return $issues;
}
/**
* @return list<array{type: string, message: string}>
*/
private function checkPermissionResourceBindings(): array
{
$rows = DB::table('admin_api_resources as ar')
->leftJoin('admin_api_resource_bindings as arb', 'arb.api_resource_id', '=', 'ar.id')
->select('ar.code', 'ar.route_name', DB::raw('count(arb.id) as binding_count'))
->where('ar.status', 1)
->where('ar.auth_mode', 'permission_required')
->groupBy('ar.id', 'ar.code', 'ar.route_name')
->havingRaw('count(arb.id) = 0')
->orderBy('ar.code')
->get();
$issues = [];
foreach ($rows as $row) {
$issues[] = [
'type' => 'resource_bindings',
'message' => sprintf(
'API resource `%s` (`%s`) requires permission but has no row in `admin_api_resource_bindings`.',
(string) $row->code,
(string) $row->route_name,
),
];
}
return $issues;
}
/**
* @return list<array{name: string, method: string, uri: string}>
*/
private function protectedAdminRoutes(): array
{
$routes = [];
/** @var IlluminateRoute $route */
foreach (Route::getRoutes() as $route) {
$middleware = $route->gatherMiddleware();
if (! in_array('lottery.admin', $middleware, true)
&& ! in_array('App\Http\Middleware\EnsureAdminApi', $middleware, true)
) {
continue;
}
$name = $route->getName();
if (! is_string($name) || $name === '') {
continue;
}
$methods = array_values(array_filter(
$route->methods(),
static fn (string $method): bool => $method !== 'HEAD',
));
$routes[] = [
'name' => $this->normalizeAdminRouteName($name),
'method' => $methods[0] ?? 'GET',
'uri' => '/'.ltrim($route->uri(), '/'),
];
}
usort($routes, static fn (array $a, array $b): int => [$a['uri'], $a['method']] <=> [$b['uri'], $b['method']]);
return $routes;
}
private function normalizeAdminRouteName(string $routeName): string
{
return preg_replace('/^(api\.v1\.admin\.)+/', 'api.v1.admin.', $routeName) ?? $routeName;
}
}