Browse Source

Apply permissions to legacy navigation

Alexander Musikhin 1 week ago
parent
commit
d933159213

+ 11 - 0
app/Helpers/roles.php

@@ -97,6 +97,17 @@ if(!function_exists('hasAnyPermission')) {
     }
     }
 }
 }
 
 
+if(!function_exists('hasAccess')) {
+    function hasAccess(string $permission, array|string|null $legacyRoles = null, $user = null): bool
+    {
+        if(!$user) $user = auth()->user();
+        if(!$user) return false;
+
+        return $user->hasPermission($permission)
+            || ($legacyRoles !== null && $user->hasRole($legacyRoles));
+    }
+}
+
 if(!function_exists('canViewField')) {
 if(!function_exists('canViewField')) {
     function canViewField(string $module, string $field, ?string $entity = null, $user = null): bool
     function canViewField(string $module, string $field, ?string $entity = null, $user = null): bool
     {
     {

+ 9 - 1
app/Http/Middleware/EnsureUserHasRole.php

@@ -2,12 +2,17 @@
 
 
 namespace App\Http\Middleware;
 namespace App\Http\Middleware;
 
 
+use App\Services\Access\AccessService;
 use Closure;
 use Closure;
 use Illuminate\Http\Request;
 use Illuminate\Http\Request;
 use Symfony\Component\HttpFoundation\Response;
 use Symfony\Component\HttpFoundation\Response;
 
 
 class EnsureUserHasRole
 class EnsureUserHasRole
 {
 {
+    public function __construct(private readonly AccessService $accessService)
+    {
+    }
+
     /**
     /**
      * Handle an incoming request.
      * Handle an incoming request.
      *
      *
@@ -18,9 +23,12 @@ class EnsureUserHasRole
      */
      */
     public function handle(Request $request, Closure $next, ... $roles): Response
     public function handle(Request $request, Closure $next, ... $roles): Response
     {
     {
-        if ($request->user()?->hasRole($roles)) {
+        $user = $request->user();
+
+        if ($user?->hasRole($roles) || ($user && $this->accessService->canAccessRoute($user, $request->route()?->getName()))) {
             return $next($request);
             return $next($request);
         }
         }
+
         abort(403);
         abort(403);
     }
     }
 }
 }

+ 37 - 0
app/Services/Access/AccessService.php

@@ -68,6 +68,43 @@ class AccessService
         abort_unless($this->can($user, $permission), 403);
         abort_unless($this->can($user, $permission), 403);
     }
     }
 
 
+    public function routePermission(?string $routeName): array|string|null
+    {
+        if (!$routeName) {
+            return null;
+        }
+
+        $exact = config('access_routes.exact.' . $routeName);
+        if ($exact) {
+            return $exact;
+        }
+
+        foreach (config('access_routes.prefixes', []) as $prefix => $permissions) {
+            if (!str_starts_with($routeName, $prefix)) {
+                continue;
+            }
+
+            $suffix = substr($routeName, strlen($prefix));
+
+            return $permissions[$suffix] ?? $permissions['*'] ?? null;
+        }
+
+        return null;
+    }
+
+    public function canAccessRoute(User $user, ?string $routeName): bool
+    {
+        $permission = $this->routePermission($routeName);
+
+        if ($permission === null) {
+            return false;
+        }
+
+        return is_array($permission)
+            ? $this->canAny($user, $permission)
+            : $this->can($user, $permission);
+    }
+
     public function roleHasPermission(Role $role, string $permission): bool
     public function roleHasPermission(Role $role, string $permission): bool
     {
     {
         if ($role->slug === Role::ADMIN) {
         if ($role->slug === Role::ADMIN) {

+ 245 - 0
config/access_routes.php

@@ -0,0 +1,245 @@
+<?php
+
+return [
+    'exact' => [
+        'area.ajax-get-areas-by-district' => 'areas.ajax.view',
+        'getFilters' => 'filters.view',
+        'notifications.index' => 'notifications.view',
+        'notifications.read-all' => 'notifications.mark_read',
+        'notifications.read' => 'notifications.mark_read',
+        'notifications.unread-count' => 'notifications.view',
+        'product.search' => 'catalog.search',
+        'pricing_codes.get_description' => 'pricing_codes.search',
+        'pricing_codes.search' => 'pricing_codes.search',
+        'user.profile' => 'profile.view',
+        'profile.store' => 'profile.update',
+        'profile.delete' => 'profile.delete',
+    ],
+
+    'prefixes' => [
+        'admin.area.' => [
+            'index' => 'areas.view',
+            'show' => 'areas.view',
+            'store' => ['areas.create', 'areas.update'],
+            'destroy' => 'areas.delete',
+            'undelete' => 'areas.restore',
+            'import' => 'areas.import',
+            'export' => 'areas.export',
+        ],
+        'admin.district.' => [
+            'index' => 'districts.view',
+            'show' => 'districts.view',
+            'store' => ['districts.create', 'districts.update'],
+            'destroy' => 'districts.delete',
+            'undelete' => 'districts.restore',
+            'import' => 'districts.import',
+            'export' => 'districts.export',
+        ],
+        'admin.roles.' => [
+            '*' => 'admin.roles',
+        ],
+        'admin.settings.' => [
+            'index' => 'admin.settings.view',
+            'store' => 'admin.settings.update',
+        ],
+        'admin.notifications.' => [
+            '*' => 'admin.notification_logs.view',
+        ],
+        'catalog.' => [
+            'index' => 'catalog.view',
+            'show' => 'catalog.view',
+            'create' => 'catalog.create',
+            'store' => 'catalog.create',
+            'update' => 'catalog.update',
+            'delete' => 'catalog.delete',
+            'export' => 'catalog.export',
+            'upload-certificate' => 'catalog.certificates.upload',
+            'delete-certificate' => 'catalog.certificates.delete',
+            'upload-thumbnail' => 'catalog.thumbnail.upload',
+        ],
+        'contract.' => [
+            'index' => 'contracts.view',
+            'show' => 'contracts.view',
+            'create' => 'contracts.create',
+            'store' => 'contracts.create',
+            'update' => 'contracts.update',
+            'delete' => 'contracts.delete',
+        ],
+        'contractors.' => [
+            'index' => 'contractors.view',
+            'show' => 'contractors.view',
+            'create' => 'contractors.create',
+            'store' => 'contractors.create',
+            'update' => 'contractors.update',
+            'prices.update' => 'contractors.prices.update',
+            'prices.import' => 'contractors.prices.import',
+            'prices.export' => 'contractors.prices.export',
+        ],
+        'clear-data.' => [
+            'index' => 'admin.clear_data.view',
+            'stats' => 'admin.clear_data.view',
+            'destroy' => 'admin.clear_data.delete',
+        ],
+        'import.' => [
+            'index' => 'import.view',
+            'show' => 'import.view',
+            'create' => 'import.create',
+        ],
+        'maf_order.' => [
+            'index' => 'maf_orders.view',
+            'show' => 'maf_orders.view',
+            'store' => 'maf_orders.create',
+            'update' => 'maf_orders.update',
+            'delete' => 'maf_orders.delete',
+            'set_in_stock' => 'maf_orders.stock.manage',
+            'set_order_in_stock' => 'maf_orders.stock.manage',
+        ],
+        'mafs.' => [
+            'import' => 'maf.import',
+            'export' => 'maf.export',
+        ],
+        'order.' => [
+            'index' => 'orders.view',
+            'show' => 'orders.view',
+            'search' => 'orders.view',
+            'create' => 'orders.create',
+            'store' => 'orders.create',
+            'edit' => 'orders.update',
+            'update' => 'orders.update',
+            'destroy' => 'orders.delete',
+            'export' => 'orders.export',
+            'export-one' => 'orders.export',
+            'upload-photo' => 'orders.photos.upload',
+            'delete-photo' => 'orders.photos.delete',
+            'delete-all-photos' => 'orders.photos.delete',
+            'upload-document' => 'orders.documents.upload',
+            'delete-document' => 'orders.documents.delete',
+            'delete-all-documents' => 'orders.documents.delete',
+            'upload-statement' => 'orders.documents.upload',
+            'delete-statement' => 'orders.documents.delete',
+            'delete-all-statements' => 'orders.documents.delete',
+            'generate-installation-pack' => 'orders.documents.generate',
+            'generate-handover-pack' => 'orders.documents.generate',
+            'generate-photos-pack' => 'orders.documents.generate',
+            'download-tech-docs' => 'orders.documents.generate',
+            'get-maf' => 'orders.maf.manage',
+            'revert-maf' => 'orders.maf.manage',
+            'move-maf' => 'orders.maf.manage',
+            'create-ttn' => 'orders.ttn.create',
+            'contractor-specification' => 'orders.contractor_specification.create',
+            'chat-messages.store' => 'orders.chat.create',
+            'chat-messages.delete' => 'orders.chat.delete',
+            'generate-reclamation-pack' => 'reclamations.documents.generate',
+        ],
+        'pricing_codes.' => [
+            'index' => 'pricing_codes.view',
+            'store' => 'pricing_codes.create',
+            'update' => 'pricing_codes.update',
+            'destroy' => 'pricing_codes.delete',
+        ],
+        'product-sku.' => [
+            'upload-passport' => 'maf.passports.upload',
+            'delete-passport' => 'maf.passports.delete',
+        ],
+        'product_sku.' => [
+            'index' => 'maf.view',
+            'show' => 'maf.view',
+            'update' => 'maf.update',
+        ],
+        'reclamations.' => [
+            'index' => 'reclamations.view',
+            'show' => 'reclamations.view',
+            'create' => 'reclamations.create',
+            'update' => 'reclamations.update',
+            'delete' => 'reclamations.delete',
+            'export' => 'reclamations.export',
+            'update-status' => 'reclamations.status.update',
+            'upload-document' => 'reclamations.documents.upload',
+            'delete-document' => 'reclamations.documents.delete',
+            'upload-photo-before' => 'reclamations.photos.upload',
+            'upload-photo-after' => 'reclamations.photos.upload',
+            'delete-photo-before' => 'reclamations.photos.delete',
+            'delete-photo-after' => 'reclamations.photos.delete',
+            'upload-act' => 'reclamations.act.upload',
+            'delete-act' => 'reclamations.act.delete',
+            'update-details' => 'reclamations.update',
+            'update-spare-parts' => 'reclamations.spare_parts.manage',
+            'chat-messages.store' => 'reclamations.chat.create',
+            'chat-messages.delete' => 'reclamations.chat.delete',
+            'generate-reclamation-payment-pack' => 'reclamations.documents.generate',
+            'generate-photos-before-pack' => 'reclamations.documents.generate',
+            'generate-photos-after-pack' => 'reclamations.documents.generate',
+        ],
+        'reports.' => [
+            '*' => 'reports.view',
+        ],
+        'responsible.' => [
+            'index' => 'responsibles.view',
+            'show' => 'responsibles.view',
+            'store' => 'responsibles.create',
+            'update' => 'responsibles.update',
+            'destroy' => 'responsibles.delete',
+        ],
+        'schedule.' => [
+            'index' => 'schedule.view',
+            'create-from-order' => 'schedule.create',
+            'create-from-reclamation' => 'schedule.create',
+            'update' => 'schedule.update',
+            'delete' => 'schedule.delete',
+            'export' => 'schedule.export',
+        ],
+        'spare_part_inventory.' => [
+            '*' => 'spare_part_inventory.view',
+        ],
+        'spare_part_orders.' => [
+            'index' => 'spare_part_orders.view',
+            'show' => 'spare_part_orders.view',
+            'create' => 'spare_part_orders.create',
+            'store' => 'spare_part_orders.create',
+            'update' => 'spare_part_orders.update',
+            'destroy' => 'spare_part_orders.delete',
+            'ship' => 'spare_part_orders.ship',
+            'set_in_stock' => 'spare_part_orders.stock.manage',
+            'set_order_in_stock' => 'spare_part_orders.stock.manage',
+            'correct' => 'spare_part_orders.correct',
+        ],
+        'spare_part_reservations.' => [
+            'for_reclamation' => 'spare_part_reservations.view',
+            'shortages_for_reclamation' => 'spare_part_reservations.view',
+            'cancel' => 'spare_part_reservations.cancel',
+            'cancel_all' => 'spare_part_reservations.cancel',
+            'issue' => 'spare_part_reservations.issue',
+            'issue_all' => 'spare_part_reservations.issue',
+            'reassign' => 'spare_part_reservations.reassign',
+        ],
+        'spare_parts.' => [
+            'index' => 'spare_parts.view',
+            'show' => 'spare_parts.view',
+            'help' => 'spare_parts.view',
+            'create' => 'spare_parts.create',
+            'store' => 'spare_parts.create',
+            'update' => 'spare_parts.update',
+            'destroy' => 'spare_parts.delete',
+            'import' => 'spare_parts.import',
+            'export' => 'spare_parts.export',
+            'upload_image' => 'spare_parts.image.upload',
+            'search' => 'spare_parts.search',
+        ],
+        'user.' => [
+            'index' => 'users.view',
+            'show' => 'users.view',
+            'create' => 'users.create',
+            'store' => ['users.create', 'users.update'],
+            'destroy' => 'users.delete',
+            'undelete' => 'users.restore',
+            'impersonate' => 'users.impersonate',
+        ],
+        'year-data.' => [
+            'index' => 'admin.year_data.view',
+            'stats' => 'admin.year_data.view',
+            'import' => 'admin.year_data.import',
+            'export' => 'admin.year_data.export',
+            'download' => 'admin.year_data.download',
+        ],
+    ],
+];

+ 45 - 21
resources/views/layouts/menu.blade.php

@@ -1,53 +1,77 @@
 @if(auth()->check())
 @if(auth()->check())
-    <li class="nav-item"><a class="nav-link @if($active == 'orders') active @endif" href="{{ route('order.index', session('gp_orders')) }}">Площадки</a></li>
+    @if(hasAccess('orders.view', \App\Models\Role::VALID_ROLES))
+        <li class="nav-item"><a class="nav-link @if($active == 'orders') active @endif" href="{{ route('order.index', session('gp_orders')) }}">Площадки</a></li>
+    @endif
 
 
-    @if(hasRole('admin,manager'))
+    @if(hasAccess('maf.view', 'admin,manager'))
         <li class="nav-item"><a class="nav-link @if($active == 'product_sku') active @endif"
         <li class="nav-item"><a class="nav-link @if($active == 'product_sku') active @endif"
                                 href="{{ route('product_sku.index', session('gp_product_sku')) }}">МАФ</a></li>
                                 href="{{ route('product_sku.index', session('gp_product_sku')) }}">МАФ</a></li>
+    @endif
+    @if(hasAccess('catalog.view', 'admin,manager'))
         <li class="nav-item"><a class="nav-link @if($active == 'catalog') active @endif"
         <li class="nav-item"><a class="nav-link @if($active == 'catalog') active @endif"
                                 href="{{ route('catalog.index', session('gp_products')) }}">Каталог</a></li>
                                 href="{{ route('catalog.index', session('gp_products')) }}">Каталог</a></li>
+    @endif
+    @if(hasAccess('reports.view', 'admin,manager'))
         <li class="nav-item"><a class="nav-link @if($active == 'reports') active @endif"
         <li class="nav-item"><a class="nav-link @if($active == 'reports') active @endif"
                                 href="{{ route('reports.index', session('gp_reports')) }}">Отчёты</a></li>
                                 href="{{ route('reports.index', session('gp_reports')) }}">Отчёты</a></li>
+    @endif
+    @if(hasAccess('responsibles.view', 'admin,manager'))
         <li class="nav-item"><a class="nav-link @if($active == 'responsibles') active @endif" href="{{ route('responsible.index', session('gp_responsibles')) }}">Ответственные</a></li>
         <li class="nav-item"><a class="nav-link @if($active == 'responsibles') active @endif" href="{{ route('responsible.index', session('gp_responsibles')) }}">Ответственные</a></li>
     @endif
     @endif
-    <li class="nav-item"><a class="nav-link @if($active == 'reclamations') active @endif"
-                            href="{{ route('reclamations.index', session('gp_reclamations')) }}">Рекламации</a></li>
+    @if(hasAccess('reclamations.view', \App\Models\Role::VALID_ROLES))
+        <li class="nav-item"><a class="nav-link @if($active == 'reclamations') active @endif"
+                                href="{{ route('reclamations.index', session('gp_reclamations')) }}">Рекламации</a></li>
+    @endif
 
 
-    @if(hasRole('admin,manager'))
+    @if(hasAccess('spare_parts.view', 'admin,manager'))
         <li class="nav-item"><a class="nav-link @if($active == 'spare_parts') active @endif"
         <li class="nav-item"><a class="nav-link @if($active == 'spare_parts') active @endif"
                                 href="{{ route('spare_parts.index', session('gp_spare_parts', [])) }}">Запчасти</a></li>
                                 href="{{ route('spare_parts.index', session('gp_spare_parts', [])) }}">Запчасти</a></li>
     @endif
     @endif
 
 
-    <li class="nav-item"><a class="nav-link @if($active == 'schedule') active @endif"
-                            href="{{ route('schedule.index', session('gp_schedule')) }}">График монтажей</a></li>
+    @if(hasAccess('schedule.view', \App\Models\Role::VALID_ROLES))
+        <li class="nav-item"><a class="nav-link @if($active == 'schedule') active @endif"
+                                href="{{ route('schedule.index', session('gp_schedule')) }}">График монтажей</a></li>
+    @endif
 
 
 
 
-    @if(hasRole('admin'))
+    @if(hasAccess('maf_orders.view', 'admin'))
         <li class="nav-item"><a class="nav-link @if($active == 'maf_order') active @endif"
         <li class="nav-item"><a class="nav-link @if($active == 'maf_order') active @endif"
                                 href="{{ route('maf_order.index', session('gp_maf_order')) }}">Заказы МАФ</a></li>
                                 href="{{ route('maf_order.index', session('gp_maf_order')) }}">Заказы МАФ</a></li>
     @endif
     @endif
-    @if(auth()->user()?->role === \App\Models\Role::ASSISTANT_HEAD)
+    @if(hasAccess('contractors.view', \App\Models\Role::ASSISTANT_HEAD))
         <li class="nav-item"><a class="nav-link @if($active == 'contractors') active @endif"
         <li class="nav-item"><a class="nav-link @if($active == 'contractors') active @endif"
                                 href="{{ route('contractors.index', session('gp_contractors')) }}">Подрядчики</a></li>
                                 href="{{ route('contractors.index', session('gp_contractors')) }}">Подрядчики</a></li>
     @endif
     @endif
-    @if(auth()->user()?->role === \App\Models\Role::ADMIN)
+    @if(hasRole('admin') || hasAnyPermission([
+        'contractors.view',
+        'contracts.view',
+        'users.view',
+        'admin.roles',
+        'admin.settings.view',
+        'districts.view',
+        'areas.view',
+        'admin.notification_logs.view',
+        'import.view',
+        'admin.year_data.view',
+        'admin.clear_data.view',
+    ]))
         <li class="nav-item dropdown">
         <li class="nav-item dropdown">
             <a id="navbarDropdown" class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"
             <a id="navbarDropdown" class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"
                aria-haspopup="true" aria-expanded="false">
                aria-haspopup="true" aria-expanded="false">
                 Администрирование
                 Администрирование
             </a>
             </a>
             <ul class="dropdown-menu dropdown-menu-end">
             <ul class="dropdown-menu dropdown-menu-end">
-                <li class="dropdown-item"><a class="nav-link" href="{{ route('contractors.index', session('gp_contractors')) }}">Подрядчики</a></li>
-                <li class="dropdown-item"><a class="nav-link" href="{{ route('contract.index', session('gp_contracts')) }}">Договоры</a></li>
-                <li class="dropdown-item"><a class="nav-link" href="{{ route('user.index', session('gp_users')) }}">Пользователи</a></li>
-                <li class="dropdown-item"><a class="nav-link" href="{{ route('admin.roles.index') }}">Роли и права</a></li>
-                <li class="dropdown-item"><a class="nav-link" href="{{ route('admin.settings.index') }}">Настройки</a></li>
-                <li class="dropdown-item"><a class="nav-link" href="{{ route('admin.district.index') }}">Округа</a></li>
-                <li class="dropdown-item"><a class="nav-link" href="{{ route('admin.area.index') }}">Районы</a></li>
-                <li class="dropdown-item"><a class="nav-link" href="{{ route('admin.notifications.log') }}">Журнал уведомлений</a></li>
-                <li class="dropdown-item"><a class="nav-link" href="{{ route('import.index', session('gp_import')) }}">Импорт</a></li>
-                <li class="dropdown-item"><a class="nav-link" href="{{ route('year-data.index') }}">Экспорт/Импорт года</a></li>
-                <li class="dropdown-item"><a class="nav-link" href="{{ route('clear-data.index') }}">Удалить данные</a></li>
+                @if(hasAccess('contractors.view', 'admin'))<li class="dropdown-item"><a class="nav-link" href="{{ route('contractors.index', session('gp_contractors')) }}">Подрядчики</a></li>@endif
+                @if(hasAccess('contracts.view', 'admin'))<li class="dropdown-item"><a class="nav-link" href="{{ route('contract.index', session('gp_contracts')) }}">Договоры</a></li>@endif
+                @if(hasAccess('users.view', 'admin'))<li class="dropdown-item"><a class="nav-link" href="{{ route('user.index', session('gp_users')) }}">Пользователи</a></li>@endif
+                @if(hasAccess('admin.roles', 'admin'))<li class="dropdown-item"><a class="nav-link" href="{{ route('admin.roles.index') }}">Роли и права</a></li>@endif
+                @if(hasAccess('admin.settings.view', 'admin'))<li class="dropdown-item"><a class="nav-link" href="{{ route('admin.settings.index') }}">Настройки</a></li>@endif
+                @if(hasAccess('districts.view', 'admin'))<li class="dropdown-item"><a class="nav-link" href="{{ route('admin.district.index') }}">Округа</a></li>@endif
+                @if(hasAccess('areas.view', 'admin'))<li class="dropdown-item"><a class="nav-link" href="{{ route('admin.area.index') }}">Районы</a></li>@endif
+                @if(hasAccess('admin.notification_logs.view', 'admin'))<li class="dropdown-item"><a class="nav-link" href="{{ route('admin.notifications.log') }}">Журнал уведомлений</a></li>@endif
+                @if(hasAccess('import.view', 'admin'))<li class="dropdown-item"><a class="nav-link" href="{{ route('import.index', session('gp_import')) }}">Импорт</a></li>@endif
+                @if(hasAccess('admin.year_data.view', 'admin'))<li class="dropdown-item"><a class="nav-link" href="{{ route('year-data.index') }}">Экспорт/Импорт года</a></li>@endif
+                @if(hasAccess('admin.clear_data.view', 'admin'))<li class="dropdown-item"><a class="nav-link" href="{{ route('clear-data.index') }}">Удалить данные</a></li>@endif
             </ul>
             </ul>
         </li>
         </li>
 
 

+ 23 - 0
tests/Feature/AdminRoleControllerTest.php

@@ -131,4 +131,27 @@ class AdminRoleControllerTest extends TestCase
         ]);
         ]);
         $this->assertFalse($user->refresh()->hasPermission('catalog.view'));
         $this->assertFalse($user->refresh()->hasPermission('catalog.view'));
     }
     }
+
+    public function test_custom_role_can_pass_legacy_role_middleware_by_route_permission(): void
+    {
+        $permission = Permission::query()->where('slug', 'admin.roles')->firstOrFail();
+        $role = Role::query()->create([
+            'slug' => 'permissions_operator',
+            'name' => 'Оператор прав',
+            'is_system' => false,
+            'is_active' => true,
+        ]);
+        $role->permissions()->sync([
+            $permission->id => ['effect' => 'allow'],
+        ]);
+        $user = User::factory()->create([
+            'role' => $role->slug,
+            'role_id' => $role->id,
+        ]);
+
+        $this->actingAs($user)
+            ->get(route('admin.roles.index'))
+            ->assertOk()
+            ->assertSee('Роли и права');
+    }
 }
 }