feat(admin): 统一后台 API 资源鉴权并完善投注风控快照与回补

This commit is contained in:
2026-05-19 09:11:50 +08:00
parent 6ef41cee76
commit 4cf561cd57
26 changed files with 1079 additions and 36 deletions

View File

@@ -0,0 +1,230 @@
<?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 资源是否绑定动作权限的检查}
{--skip-role-resource-sync : 跳过 role_menu_actions role_api_resources 一致性检查}';
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 (! (bool) $this->option('skip-role-resource-sync')) {
$issues = array_merge($issues, $this->checkRoleApiResourceSync());
}
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{type: string, message: string}>
*/
private function checkRoleApiResourceSync(): array
{
$expectedRows = DB::table('admin_role_menu_actions as rma')
->join('admin_api_resource_bindings as arb', 'arb.menu_action_id', '=', 'rma.menu_action_id')
->select('rma.role_id', 'arb.api_resource_id')
->distinct()
->get();
$expectedSet = [];
foreach ($expectedRows as $row) {
$expectedSet[$this->roleApiKey((int) $row->role_id, (int) $row->api_resource_id)] = true;
}
$actualRows = DB::table('admin_role_api_resources')
->select('role_id', 'api_resource_id')
->get();
$actualSet = [];
foreach ($actualRows as $row) {
$actualSet[$this->roleApiKey((int) $row->role_id, (int) $row->api_resource_id)] = true;
}
$roleSlugs = DB::table('admin_roles')->pluck('slug', 'id')->all();
$resourceCodes = DB::table('admin_api_resources')->pluck('code', 'id')->all();
$issues = [];
foreach (array_keys($expectedSet) as $key) {
if (isset($actualSet[$key])) {
continue;
}
[$roleId, $resourceId] = array_map('intval', explode(':', $key, 2));
$issues[] = [
'type' => 'role_resource_sync',
'message' => sprintf(
'Missing role-resource grant: role `%s` should include API resource `%s`.',
(string) ($roleSlugs[$roleId] ?? $roleId),
(string) ($resourceCodes[$resourceId] ?? $resourceId),
),
];
}
foreach (array_keys($actualSet) as $key) {
if (isset($expectedSet[$key])) {
continue;
}
[$roleId, $resourceId] = array_map('intval', explode(':', $key, 2));
$issues[] = [
'type' => 'role_resource_sync',
'message' => sprintf(
'Extra role-resource grant: role `%s` has API resource `%s` without any supporting action binding.',
(string) ($roleSlugs[$roleId] ?? $roleId),
(string) ($resourceCodes[$resourceId] ?? $resourceId),
),
];
}
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;
}
private function roleApiKey(int $roleId, int $apiResourceId): string
{
return $roleId.':'.$apiResourceId;
}
}