Browse Source

Add RBAC infrastructure

Alexander Musikhin 1 week ago
parent
commit
564b4fa360

+ 42 - 5
app/Helpers/roles.php

@@ -20,17 +20,54 @@ if(!function_exists('hasRole')){
         if(!$user) $user = auth()->user();
         if(!$user) $user = auth()->user();
         if(!$user) return false;
         if(!$user) return false;
 
 
-        $roles = explode(',', $roles);
-        $effectiveRoles = Role::effectiveRoles($user->role);
-
-        return count(array_intersect($roles, $effectiveRoles)) > 0;
+        return $user->hasRole($roles);
     }
     }
 }
 }
 
 
 if(!function_exists('roleName')) {
 if(!function_exists('roleName')) {
     function roleName($role): string
     function roleName($role): string
     {
     {
-        return Role::NAMES[$role];
+        return Role::NAMES[$role] ?? (string) $role;
+    }
+}
+
+if(!function_exists('hasPermission')) {
+    function hasPermission(string $permission, $user = null): bool
+    {
+        if(!$user) $user = auth()->user();
+        if(!$user) return false;
+
+        return $user->hasPermission($permission);
+    }
+}
+
+if(!function_exists('hasAnyPermission')) {
+    function hasAnyPermission(array|string $permissions, $user = null): bool
+    {
+        if(!$user) $user = auth()->user();
+        if(!$user) return false;
+
+        return $user->hasAnyPermission($permissions);
+    }
+}
+
+if(!function_exists('canViewField')) {
+    function canViewField(string $module, string $field, ?string $entity = null, $user = null): bool
+    {
+        if(!$user) $user = auth()->user();
+        if(!$user) return false;
+
+        return $user->canViewField($module, $field, $entity);
+    }
+}
+
+if(!function_exists('canUpdateField')) {
+    function canUpdateField(string $module, string $field, ?string $entity = null, $user = null): bool
+    {
+        if(!$user) $user = auth()->user();
+        if(!$user) return false;
+
+        return $user->canUpdateField($module, $field, $entity);
     }
     }
 }
 }
 
 

+ 19 - 0
app/Http/Middleware/EnsureUserHasAnyPermission.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace App\Http\Middleware;
+
+use Closure;
+use Illuminate\Http\Request;
+use Symfony\Component\HttpFoundation\Response;
+
+class EnsureUserHasAnyPermission
+{
+    public function handle(Request $request, Closure $next, string $permissions): Response
+    {
+        if ($request->user()?->hasAnyPermission(explode(',', $permissions))) {
+            return $next($request);
+        }
+
+        abort(403);
+    }
+}

+ 19 - 0
app/Http/Middleware/EnsureUserHasPermission.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace App\Http\Middleware;
+
+use Closure;
+use Illuminate\Http\Request;
+use Symfony\Component\HttpFoundation\Response;
+
+class EnsureUserHasPermission
+{
+    public function handle(Request $request, Closure $next, string $permission): Response
+    {
+        if ($request->user()?->hasPermission($permission)) {
+            return $next($request);
+        }
+
+        abort(403);
+    }
+}

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

@@ -2,7 +2,6 @@
 
 
 namespace App\Http\Middleware;
 namespace App\Http\Middleware;
 
 
-use App\Models\Role;
 use Closure;
 use Closure;
 use Illuminate\Http\Request;
 use Illuminate\Http\Request;
 use Symfony\Component\HttpFoundation\Response;
 use Symfony\Component\HttpFoundation\Response;
@@ -19,7 +18,7 @@ class EnsureUserHasRole
      */
      */
     public function handle(Request $request, Closure $next, ... $roles): Response
     public function handle(Request $request, Closure $next, ... $roles): Response
     {
     {
-        if (count(array_intersect($roles, Role::effectiveRoles($request->user()->role))) > 0) {
+        if ($request->user()?->hasRole($roles)) {
             return $next($request);
             return $next($request);
         }
         }
         abort(403);
         abort(403);

+ 70 - 0
app/Models/Permission.php

@@ -0,0 +1,70 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsToMany;
+use Illuminate\Support\Collection;
+
+class Permission extends Model
+{
+    public const TYPE_ACTION = 'action';
+    public const TYPE_FIELD = 'field';
+
+    protected $fillable = [
+        'slug',
+        'name',
+        'description',
+        'module',
+        'entity',
+        'field',
+        'action',
+        'type',
+        'group',
+        'sort',
+        'is_system',
+    ];
+
+    protected function casts(): array
+    {
+        return [
+            'is_system' => 'boolean',
+        ];
+    }
+
+    public function roles(): BelongsToMany
+    {
+        return $this->belongsToMany(Role::class, 'role_permissions')
+            ->withPivot('effect')
+            ->withTimestamps();
+    }
+
+    public function users(): BelongsToMany
+    {
+        return $this->belongsToMany(User::class, 'user_permissions')
+            ->withPivot(['effect', 'reason', 'expires_at'])
+            ->withTimestamps();
+    }
+
+    public function scopeActionPermissions(Builder $query): Builder
+    {
+        return $query->where('type', self::TYPE_ACTION);
+    }
+
+    public function scopeFieldPermissions(Builder $query): Builder
+    {
+        return $query->where('type', self::TYPE_FIELD);
+    }
+
+    public static function getGroupedForUi(): Collection
+    {
+        return self::query()
+            ->orderBy('sort')
+            ->orderBy('module')
+            ->orderBy('type')
+            ->orderBy('slug')
+            ->get()
+            ->groupBy('group');
+    }
+}

+ 69 - 1
app/Models/Role.php

@@ -2,7 +2,11 @@
 
 
 namespace App\Models;
 namespace App\Models;
 
 
-class Role
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsToMany;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+
+class Role extends Model
 {
 {
     const ADMIN = 'admin';
     const ADMIN = 'admin';
     const MANAGER = 'manager';
     const MANAGER = 'manager';
@@ -26,6 +30,70 @@ class Role
         self::ASSISTANT_HEAD => 'Помощник рук.',
         self::ASSISTANT_HEAD => 'Помощник рук.',
     ];
     ];
 
 
+    protected $fillable = [
+        'slug',
+        'name',
+        'description',
+        'is_system',
+        'is_active',
+        'sort',
+    ];
+
+    protected function casts(): array
+    {
+        return [
+            'is_system' => 'boolean',
+            'is_active' => 'boolean',
+        ];
+    }
+
+    public function permissions(): BelongsToMany
+    {
+        return $this->belongsToMany(Permission::class, 'role_permissions')
+            ->withPivot('effect')
+            ->withTimestamps();
+    }
+
+    public function users(): HasMany
+    {
+        return $this->hasMany(User::class);
+    }
+
+    public function hasPermission(string $permission): bool
+    {
+        return app(\App\Services\Access\AccessService::class)->roleHasPermission($this, $permission);
+    }
+
+    public function givePermission(string $permission, string $effect = 'allow'): void
+    {
+        $permissionModel = Permission::query()->where('slug', $permission)->firstOrFail();
+
+        $this->permissions()->syncWithoutDetaching([
+            $permissionModel->id => ['effect' => $effect],
+        ]);
+
+        app(\App\Services\Access\AccessService::class)->bumpCacheVersion();
+    }
+
+    public function syncPermissions(array $permissions): void
+    {
+        $sync = [];
+        foreach ($permissions as $permission => $effect) {
+            if (is_int($permission)) {
+                $permission = $effect;
+                $effect = 'allow';
+            }
+
+            $permissionModel = Permission::query()->where('slug', $permission)->first();
+            if ($permissionModel) {
+                $sync[$permissionModel->id] = ['effect' => $effect];
+            }
+        }
+
+        $this->permissions()->sync($sync);
+        app(\App\Services\Access\AccessService::class)->bumpCacheVersion();
+    }
+
     public static function effectiveRoles(string $role): array
     public static function effectiveRoles(string $role): array
     {
     {
         return match ($role) {
         return match ($role) {

+ 70 - 0
app/Models/User.php

@@ -4,6 +4,8 @@ namespace App\Models;
 
 
 use Illuminate\Contracts\Auth\MustVerifyEmail;
 use Illuminate\Contracts\Auth\MustVerifyEmail;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 use Illuminate\Database\Eloquent\Relations\HasMany;
 use Illuminate\Database\Eloquent\Relations\HasMany;
 use Illuminate\Database\Eloquent\SoftDeletes;
 use Illuminate\Database\Eloquent\SoftDeletes;
 use Illuminate\Foundation\Auth\User as Authenticatable;
 use Illuminate\Foundation\Auth\User as Authenticatable;
@@ -28,6 +30,7 @@ class User extends Authenticatable implements MustVerifyEmail
         'phone',
         'phone',
         'password',
         'password',
         'role',
         'role',
+        'role_id',
         'color',
         'color',
         'token_fcm',
         'token_fcm',
     ];
     ];
@@ -80,6 +83,73 @@ class User extends Authenticatable implements MustVerifyEmail
         return $this->userNotifications()->whereNull('read_at');
         return $this->userNotifications()->whereNull('read_at');
     }
     }
 
 
+    public function roleModel(): BelongsTo
+    {
+        return $this->belongsTo(Role::class, 'role_id');
+    }
+
+    public function permissions(): BelongsToMany
+    {
+        return $this->belongsToMany(Permission::class, 'user_permissions')
+            ->withPivot(['effect', 'reason', 'expires_at'])
+            ->withTimestamps();
+    }
+
+    public function hasRole(string|array $roles): bool
+    {
+        $roles = is_array($roles) ? $roles : explode(',', $roles);
+        $roles = array_map('trim', $roles);
+
+        $role = $this->resolvedRoleSlug();
+        if (!$role) {
+            return false;
+        }
+
+        return count(array_intersect($roles, Role::effectiveRoles($role))) > 0;
+    }
+
+    public function hasPermission(string $permission): bool
+    {
+        return app(\App\Services\Access\AccessService::class)->can($this, $permission);
+    }
+
+    public function hasAnyPermission(array|string $permissions): bool
+    {
+        $permissions = is_array($permissions) ? $permissions : explode(',', $permissions);
+
+        return app(\App\Services\Access\AccessService::class)->canAny($this, $permissions);
+    }
+
+    public function canViewField(string $module, string $field, ?string $entity = null): bool
+    {
+        return app(\App\Services\Access\AccessService::class)->canViewField($this, $module, $field, $entity);
+    }
+
+    public function canUpdateField(string $module, string $field, ?string $entity = null): bool
+    {
+        return app(\App\Services\Access\AccessService::class)->canUpdateField($this, $module, $field, $entity);
+    }
+
+    public function getEffectivePermissions(): \Illuminate\Support\Collection
+    {
+        return app(\App\Services\Access\AccessService::class)->getEffectivePermissions($this);
+    }
+
+    public function resolvedRoleSlug(): ?string
+    {
+        if ($this->getAttribute('role_id')) {
+            $role = $this->relationLoaded('roleModel')
+                ? $this->roleModel
+                : $this->roleModel()->first();
+
+            if ($role) {
+                return $role->slug;
+            }
+        }
+
+        return $this->role;
+    }
+
     public static function assignUniqueFcmToken(int $userId, string $token): void
     public static function assignUniqueFcmToken(int $userId, string $token): void
     {
     {
         DB::transaction(function () use ($userId, $token) {
         DB::transaction(function () use ($userId, $token) {

+ 7 - 0
app/Providers/AppServiceProvider.php

@@ -5,6 +5,7 @@ namespace App\Providers;
 use App\Models\SparePartOrder;
 use App\Models\SparePartOrder;
 use App\Observers\SparePartOrderObserver;
 use App\Observers\SparePartOrderObserver;
 use Illuminate\Pagination\Paginator;
 use Illuminate\Pagination\Paginator;
+use Illuminate\Support\Facades\Blade;
 use Illuminate\Support\Facades\URL;
 use Illuminate\Support\Facades\URL;
 use Illuminate\Support\ServiceProvider;
 use Illuminate\Support\ServiceProvider;
 
 
@@ -28,6 +29,12 @@ class AppServiceProvider extends ServiceProvider
             URL::forceScheme('https');
             URL::forceScheme('https');
         }
         }
 
 
+        Blade::if('role', fn($roles) => hasRole($roles));
+        Blade::if('permission', fn($permission) => hasPermission($permission));
+        Blade::if('anypermission', fn($permissions) => hasAnyPermission($permissions));
+        Blade::if('fieldView', fn($module, $field, $entity = null) => canViewField($module, $field, $entity));
+        Blade::if('fieldUpdate', fn($module, $field, $entity = null) => canUpdateField($module, $field, $entity));
+
         // Регистрация Observer для автоматической обработки дефицитов
         // Регистрация Observer для автоматической обработки дефицитов
         SparePartOrder::observe(SparePartOrderObserver::class);
         SparePartOrder::observe(SparePartOrderObserver::class);
     }
     }

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

@@ -0,0 +1,162 @@
+<?php
+
+namespace App\Services\Access;
+
+use App\Models\Permission;
+use App\Models\Role;
+use App\Models\User;
+use Illuminate\Database\QueryException;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Schema;
+
+class AccessService
+{
+    private const CACHE_VERSION_KEY = 'permissions:version';
+
+    public function can(User $user, string $permission): bool
+    {
+        if ($this->isDirectAdmin($user)) {
+            return true;
+        }
+
+        $effect = $this->getEffectivePermissions($user)->get($permission);
+
+        return $effect === 'allow';
+    }
+
+    public function canAny(User $user, array $permissions): bool
+    {
+        foreach ($permissions as $permission) {
+            if ($this->can($user, trim((string) $permission))) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    public function canViewField(User $user, string $module, string $field, ?string $entity = null): bool
+    {
+        return $this->can($user, $this->fieldPermissionSlug($module, $field, 'view'));
+    }
+
+    public function canUpdateField(User $user, string $module, string $field, ?string $entity = null): bool
+    {
+        return $this->can($user, $this->fieldPermissionSlug($module, $field, 'update'));
+    }
+
+    public function filterReadableFields(User $user, string $module, array $fields, ?string $entity = null): array
+    {
+        return array_values(array_filter(
+            $fields,
+            fn (string $field): bool => $this->canViewField($user, $module, $field, $entity)
+        ));
+    }
+
+    public function filterWritableData(User $user, string $module, array $data, ?string $entity = null): array
+    {
+        return array_filter(
+            $data,
+            fn (string $field): bool => $this->canUpdateField($user, $module, $field, $entity),
+            ARRAY_FILTER_USE_KEY
+        );
+    }
+
+    public function assertCan(User $user, string $permission): void
+    {
+        abort_unless($this->can($user, $permission), 403);
+    }
+
+    public function roleHasPermission(Role $role, string $permission): bool
+    {
+        if ($role->slug === Role::ADMIN) {
+            return true;
+        }
+
+        $permissionModel = $role->permissions()
+            ->where('slug', $permission)
+            ->first();
+
+        return $permissionModel?->pivot?->effect === 'allow';
+    }
+
+    public function getEffectivePermissions(User $user): Collection
+    {
+        if ($this->isDirectAdmin($user)) {
+            try {
+                return Permission::query()->pluck('slug')->mapWithKeys(fn (string $slug): array => [$slug => 'allow']);
+            } catch (QueryException) {
+                return collect();
+            }
+        }
+
+        return Cache::remember(
+            $this->cacheKey($user),
+            now()->addHour(),
+            fn (): Collection => $this->resolveEffectivePermissions($user)
+        );
+    }
+
+    public function bumpCacheVersion(): void
+    {
+        Cache::forever(self::CACHE_VERSION_KEY, $this->cacheVersion() + 1);
+    }
+
+    private function resolveEffectivePermissions(User $user): Collection
+    {
+        if (!$this->tablesExist()) {
+            return collect();
+        }
+
+        $effects = collect();
+
+        $role = $user->roleModel()->with('permissions')->first();
+        if ($role) {
+            foreach ($role->permissions as $permission) {
+                $effects->put($permission->slug, $permission->pivot->effect);
+            }
+        }
+
+        $userPermissions = $user->permissions()
+            ->where(function ($query) {
+                $query->whereNull('expires_at')
+                    ->orWhere('expires_at', '>', now());
+            })
+            ->get();
+
+        foreach ($userPermissions as $permission) {
+            $effects->put($permission->slug, $permission->pivot->effect);
+        }
+
+        return $effects;
+    }
+
+    private function isDirectAdmin(User $user): bool
+    {
+        return $user->resolvedRoleSlug() === Role::ADMIN;
+    }
+
+    private function fieldPermissionSlug(string $module, string $field, string $action): string
+    {
+        return "{$module}.fields.{$field}.{$action}";
+    }
+
+    private function cacheKey(User $user): string
+    {
+        return 'permissions:user:' . $user->id . ':v' . $this->cacheVersion();
+    }
+
+    private function cacheVersion(): int
+    {
+        return (int) Cache::get(self::CACHE_VERSION_KEY, 1);
+    }
+
+    private function tablesExist(): bool
+    {
+        return Schema::hasTable('roles')
+            && Schema::hasTable('permissions')
+            && Schema::hasTable('role_permissions')
+            && Schema::hasTable('user_permissions');
+    }
+}

+ 43 - 0
app/Services/Access/FieldAccessService.php

@@ -0,0 +1,43 @@
+<?php
+
+namespace App\Services\Access;
+
+use App\Models\User;
+
+class FieldAccessService
+{
+    public function __construct(private readonly AccessService $accessService)
+    {
+    }
+
+    public function visibleHeaders(User $user, string $module, array $headers): array
+    {
+        return array_filter(
+            $headers,
+            fn (string $field): bool => $this->accessService->canViewField($user, $module, $this->normalizeField($field)),
+            ARRAY_FILTER_USE_KEY
+        );
+    }
+
+    public function filterValidatedPayload(User $user, string $module, array $validated): array
+    {
+        return $this->accessService->filterWritableData($user, $module, $validated);
+    }
+
+    public function filterExportColumns(User $user, string $module, array $columns): array
+    {
+        return $columns;
+    }
+
+    public function filterImportPayload(User $user, string $module, array $row): array
+    {
+        return $row;
+    }
+
+    private function normalizeField(string $field): string
+    {
+        return str_ends_with($field, '_txt')
+            ? substr($field, 0, -4)
+            : $field;
+    }
+}

+ 4 - 0
bootstrap/app.php

@@ -1,6 +1,8 @@
 <?php
 <?php
 
 
 use App\Http\Middleware\CatchTokenFcmMiddleware;
 use App\Http\Middleware\CatchTokenFcmMiddleware;
+use App\Http\Middleware\EnsureUserHasAnyPermission;
+use App\Http\Middleware\EnsureUserHasPermission;
 use App\Http\Middleware\EnsureUserHasRole;
 use App\Http\Middleware\EnsureUserHasRole;
 use App\Http\Middleware\TrackLastWebPageMiddleware;
 use App\Http\Middleware\TrackLastWebPageMiddleware;
 use Illuminate\Foundation\Application;
 use Illuminate\Foundation\Application;
@@ -17,6 +19,8 @@ return Application::configure(basePath: dirname(__DIR__))
     ->withMiddleware(function (Middleware $middleware) {
     ->withMiddleware(function (Middleware $middleware) {
         $middleware->alias([
         $middleware->alias([
             'role' => EnsureUserHasRole::class,
             'role' => EnsureUserHasRole::class,
+            'permission' => EnsureUserHasPermission::class,
+            'permission.any' => EnsureUserHasAnyPermission::class,
         ]);
         ]);
         $middleware->append(TrackLastWebPageMiddleware::class);
         $middleware->append(TrackLastWebPageMiddleware::class);
         $middleware->append(CatchTokenFcmMiddleware::class);
         $middleware->append(CatchTokenFcmMiddleware::class);

+ 230 - 0
config/access.php

@@ -0,0 +1,230 @@
+<?php
+
+return [
+    'orders' => [
+        'name' => 'Площадки',
+        'entity' => 'order',
+        'actions' => [
+            'view' => 'Просмотр',
+            'create' => 'Создание',
+            'update' => 'Редактирование',
+            'delete' => 'Удаление',
+            'export' => 'Экспорт',
+            'photos.upload' => 'Загрузка фото',
+            'photos.delete' => 'Удаление фото',
+            'documents.upload' => 'Загрузка документов',
+            'documents.delete' => 'Удаление документов',
+            'documents.generate' => 'Генерация документов',
+            'maf.manage' => 'Управление МАФ',
+            'ttn.create' => 'Создание ТТН',
+            'contractor_specification.create' => 'Спецификация подрядчика',
+            'chat.create' => 'Сообщения',
+            'chat.delete' => 'Удаление сообщений',
+        ],
+    ],
+    'reclamations' => [
+        'name' => 'Рекламации',
+        'entity' => 'reclamation',
+        'actions' => [
+            'view' => 'Просмотр',
+            'create' => 'Создание',
+            'update' => 'Редактирование',
+            'delete' => 'Удаление',
+            'export' => 'Экспорт',
+            'status.update' => 'Изменение статуса',
+            'documents.upload' => 'Загрузка документов',
+            'documents.delete' => 'Удаление документов',
+            'documents.generate' => 'Генерация документов',
+            'photos.upload' => 'Загрузка фото',
+            'photos.delete' => 'Удаление фото',
+            'act.upload' => 'Загрузка акта',
+            'act.delete' => 'Удаление акта',
+            'spare_parts.manage' => 'Управление запчастями',
+            'chat.create' => 'Сообщения',
+            'chat.delete' => 'Удаление сообщений',
+        ],
+    ],
+    'schedule' => [
+        'name' => 'График монтажей',
+        'entity' => 'schedule',
+        'actions' => [
+            'view' => 'Просмотр',
+            'create' => 'Создание',
+            'update' => 'Редактирование',
+            'delete' => 'Удаление',
+            'export' => 'Экспорт',
+        ],
+    ],
+    'catalog' => [
+        'name' => 'Каталог',
+        'entity' => 'product',
+        'actions' => [
+            'view' => 'Просмотр',
+            'create' => 'Создание',
+            'update' => 'Редактирование',
+            'delete' => 'Удаление',
+            'export' => 'Экспорт',
+            'certificates.upload' => 'Загрузка сертификата',
+            'certificates.delete' => 'Удаление сертификата',
+            'thumbnail.upload' => 'Загрузка изображения',
+            'search' => 'Поиск',
+        ],
+        'fields' => [
+            'image' => 'Картинка',
+            'id' => 'ID',
+            'article' => 'Артикул',
+            'nomenclature_number' => 'Номер номенклатуры',
+            'manufacturer' => 'Производитель',
+            'unit' => 'Ед. изм.',
+            'name_tz' => 'Наименование ТЗ',
+            'type_tz' => 'Тип по ТЗ',
+            'type' => 'Тип',
+            'manufacturer_name' => 'Наименование производителя',
+            'sizes' => 'Размеры',
+            'product_price' => 'Цена товара',
+            'installation_price' => 'Цена установки',
+            'total_price' => 'Итоговая цена',
+            'note' => 'Примечания',
+            'created_at' => 'Дата создания',
+            'certificate_id' => 'Сертификат',
+            'passport_name' => 'Наименование по паспорту',
+            'statement_name' => 'Наименование в ведомости',
+            'service_life' => 'Срок службы',
+            'certificate_number' => 'Номер сертификата',
+            'certificate_date' => 'Дата сертификата',
+            'certificate_issuer' => 'Орган сертификации',
+            'certificate_type' => 'Вид сертификации',
+            'weight' => 'Вес',
+            'volume' => 'Объем',
+            'places' => 'Мест',
+        ],
+    ],
+    'maf' => [
+        'name' => 'МАФ',
+        'entity' => 'product_sku',
+        'actions' => [
+            'view' => 'Просмотр',
+            'update' => 'Редактирование',
+            'import' => 'Импорт',
+            'export' => 'Экспорт',
+            'passports.upload' => 'Загрузка паспорта',
+            'passports.delete' => 'Удаление паспорта',
+        ],
+    ],
+    'maf_orders' => [
+        'name' => 'Заказы МАФ',
+        'entity' => 'maf_order',
+        'actions' => [
+            'view' => 'Просмотр',
+            'create' => 'Создание',
+            'update' => 'Редактирование',
+            'delete' => 'Удаление',
+            'stock.manage' => 'Управление остатками',
+        ],
+    ],
+    'contracts' => [
+        'name' => 'Договоры',
+        'entity' => 'contract',
+        'actions' => ['view' => 'Просмотр', 'create' => 'Создание', 'update' => 'Редактирование', 'delete' => 'Удаление'],
+    ],
+    'contractors' => [
+        'name' => 'Подрядчики',
+        'entity' => 'contractor',
+        'actions' => [
+            'view' => 'Просмотр',
+            'create' => 'Создание',
+            'update' => 'Редактирование',
+            'delete' => 'Удаление',
+            'prices.update' => 'Редактирование цен',
+            'prices.import' => 'Импорт цен',
+            'prices.export' => 'Экспорт цен',
+        ],
+    ],
+    'responsibles' => [
+        'name' => 'Ответственные',
+        'entity' => 'responsible',
+        'actions' => ['view' => 'Просмотр', 'create' => 'Создание', 'update' => 'Редактирование', 'delete' => 'Удаление'],
+    ],
+    'reports' => [
+        'name' => 'Отчеты',
+        'actions' => ['view' => 'Просмотр'],
+    ],
+    'users' => [
+        'name' => 'Пользователи',
+        'entity' => 'user',
+        'actions' => [
+            'view' => 'Просмотр',
+            'create' => 'Создание',
+            'update' => 'Редактирование',
+            'delete' => 'Удаление',
+            'restore' => 'Восстановление',
+            'impersonate' => 'Вход под пользователем',
+            'permissions.manage' => 'Управление правами',
+        ],
+    ],
+    'profile' => [
+        'name' => 'Профиль',
+        'actions' => ['view' => 'Просмотр', 'update' => 'Редактирование', 'delete' => 'Удаление'],
+    ],
+    'import' => [
+        'name' => 'Импорт',
+        'actions' => ['view' => 'Просмотр', 'create' => 'Создание'],
+    ],
+    'admin' => [
+        'name' => 'Администрирование',
+        'actions' => [
+            'settings.view' => 'Настройки: просмотр',
+            'settings.update' => 'Настройки: редактирование',
+            'clear_data.view' => 'Удаление данных: просмотр',
+            'clear_data.delete' => 'Удаление данных',
+            'year_data.view' => 'Данные года: просмотр',
+            'year_data.import' => 'Данные года: импорт',
+            'year_data.export' => 'Данные года: экспорт',
+            'year_data.download' => 'Данные года: скачивание',
+            'notification_logs.view' => 'Журнал уведомлений',
+            'roles' => 'Роли и права',
+        ],
+    ],
+    'districts' => [
+        'name' => 'Округа',
+        'actions' => ['view' => 'Просмотр', 'create' => 'Создание', 'update' => 'Редактирование', 'delete' => 'Удаление', 'restore' => 'Восстановление', 'import' => 'Импорт', 'export' => 'Экспорт'],
+    ],
+    'areas' => [
+        'name' => 'Районы',
+        'actions' => ['view' => 'Просмотр', 'create' => 'Создание', 'update' => 'Редактирование', 'delete' => 'Удаление', 'restore' => 'Восстановление', 'import' => 'Импорт', 'export' => 'Экспорт', 'ajax.view' => 'Ajax просмотр'],
+    ],
+    'notifications' => [
+        'name' => 'Уведомления',
+        'actions' => ['view' => 'Просмотр', 'mark_read' => 'Отметка прочтения'],
+    ],
+    'spare_parts' => [
+        'name' => 'Запчасти',
+        'entity' => 'spare_part',
+        'actions' => ['view' => 'Просмотр', 'create' => 'Создание', 'update' => 'Редактирование', 'delete' => 'Удаление', 'import' => 'Импорт', 'export' => 'Экспорт', 'image.upload' => 'Загрузка изображения', 'search' => 'Поиск'],
+    ],
+    'spare_part_orders' => [
+        'name' => 'Заказы запчастей',
+        'entity' => 'spare_part_order',
+        'actions' => ['view' => 'Просмотр', 'create' => 'Создание', 'update' => 'Редактирование', 'delete' => 'Удаление', 'ship' => 'Отгрузка', 'stock.manage' => 'Управление остатками', 'correct' => 'Корректировка'],
+    ],
+    'spare_part_reservations' => [
+        'name' => 'Резервы запчастей',
+        'actions' => ['view' => 'Просмотр', 'manage' => 'Управление', 'issue' => 'Выдача', 'cancel' => 'Отмена', 'reassign' => 'Переназначение'],
+    ],
+    'spare_part_inventory' => [
+        'name' => 'Контроль наличия',
+        'actions' => ['view' => 'Просмотр'],
+    ],
+    'pricing_codes' => [
+        'name' => 'Шифры расценок',
+        'actions' => ['view' => 'Просмотр', 'create' => 'Создание', 'update' => 'Редактирование', 'delete' => 'Удаление', 'search' => 'Поиск'],
+    ],
+    'chat_messages' => [
+        'name' => 'Чат',
+        'actions' => ['create' => 'Создание сообщений', 'delete' => 'Удаление сообщений', 'notify' => 'Уведомления из чата'],
+    ],
+    'filters' => [
+        'name' => 'Фильтры',
+        'actions' => ['view' => 'Просмотр'],
+    ],
+];

+ 27 - 0
database/migrations/2026_05_14_000001_create_roles_table.php

@@ -0,0 +1,27 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::create('roles', function (Blueprint $table) {
+            $table->id();
+            $table->string('slug')->unique();
+            $table->string('name');
+            $table->text('description')->nullable();
+            $table->boolean('is_system')->default(false);
+            $table->boolean('is_active')->default(true);
+            $table->unsignedInteger('sort')->default(100);
+            $table->timestamps();
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('roles');
+    }
+};

+ 35 - 0
database/migrations/2026_05_14_000002_create_permissions_table.php

@@ -0,0 +1,35 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::create('permissions', function (Blueprint $table) {
+            $table->id();
+            $table->string('slug')->unique();
+            $table->string('name');
+            $table->text('description')->nullable();
+            $table->string('module');
+            $table->string('entity')->nullable();
+            $table->string('field')->nullable();
+            $table->string('action');
+            $table->string('type')->default('action');
+            $table->string('group')->nullable();
+            $table->unsignedInteger('sort')->default(100);
+            $table->boolean('is_system')->default(true);
+            $table->timestamps();
+
+            $table->index(['module', 'type']);
+            $table->index(['module', 'field']);
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('permissions');
+    }
+};

+ 25 - 0
database/migrations/2026_05_14_000003_create_role_permissions_table.php

@@ -0,0 +1,25 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::create('role_permissions', function (Blueprint $table) {
+            $table->foreignId('role_id')->constrained()->cascadeOnDelete();
+            $table->foreignId('permission_id')->constrained()->cascadeOnDelete();
+            $table->string('effect')->default('allow');
+            $table->timestamps();
+
+            $table->primary(['role_id', 'permission_id']);
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('role_permissions');
+    }
+};

+ 27 - 0
database/migrations/2026_05_14_000004_create_user_permissions_table.php

@@ -0,0 +1,27 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::create('user_permissions', function (Blueprint $table) {
+            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
+            $table->foreignId('permission_id')->constrained()->cascadeOnDelete();
+            $table->string('effect')->default('allow');
+            $table->text('reason')->nullable();
+            $table->timestamp('expires_at')->nullable();
+            $table->timestamps();
+
+            $table->primary(['user_id', 'permission_id']);
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('user_permissions');
+    }
+};

+ 26 - 0
database/migrations/2026_05_14_000005_add_role_id_to_users_table.php

@@ -0,0 +1,26 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::table('users', function (Blueprint $table) {
+            $table->foreignId('role_id')
+                ->nullable()
+                ->after('role')
+                ->constrained('roles')
+                ->nullOnDelete();
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('users', function (Blueprint $table) {
+            $table->dropConstrainedForeignId('role_id');
+        });
+    }
+};

+ 207 - 0
database/seeders/RbacSeeder.php

@@ -0,0 +1,207 @@
+<?php
+
+namespace Database\Seeders;
+
+use App\Models\Permission;
+use App\Models\Role;
+use App\Models\User;
+use App\Services\Access\AccessService;
+use Illuminate\Database\Seeder;
+use Illuminate\Support\Facades\DB;
+
+class RbacSeeder extends Seeder
+{
+    public function run(): void
+    {
+        DB::transaction(function () {
+            $roles = $this->seedRoles();
+            $permissions = $this->seedPermissions();
+
+            $this->backfillUserRoles($roles);
+            $this->seedRolePermissions($roles, $permissions);
+        });
+
+        app(AccessService::class)->bumpCacheVersion();
+    }
+
+    private function seedRoles(): array
+    {
+        $roles = [];
+        foreach (Role::NAMES as $slug => $name) {
+            $roles[$slug] = Role::query()->updateOrCreate(
+                ['slug' => $slug],
+                [
+                    'name' => $name,
+                    'is_system' => true,
+                    'is_active' => true,
+                    'sort' => array_search($slug, Role::VALID_ROLES, true) + 10,
+                ],
+            );
+        }
+
+        return $roles;
+    }
+
+    private function seedPermissions(): array
+    {
+        $permissions = [];
+        $sort = 10;
+
+        foreach (config('access', []) as $module => $definition) {
+            foreach (($definition['actions'] ?? []) as $action => $name) {
+                $slug = "{$module}.{$action}";
+                $permissions[$slug] = Permission::query()->updateOrCreate(
+                    ['slug' => $slug],
+                    [
+                        'name' => $name,
+                        'module' => $module,
+                        'entity' => $definition['entity'] ?? null,
+                        'field' => null,
+                        'action' => $action,
+                        'type' => Permission::TYPE_ACTION,
+                        'group' => $definition['name'] ?? $module,
+                        'sort' => $sort++,
+                        'is_system' => true,
+                    ],
+                );
+            }
+
+            foreach (($definition['fields'] ?? []) as $field => $name) {
+                foreach (['view' => 'Просмотр', 'update' => 'Редактирование'] as $action => $actionName) {
+                    $slug = "{$module}.fields.{$field}.{$action}";
+                    $permissions[$slug] = Permission::query()->updateOrCreate(
+                        ['slug' => $slug],
+                        [
+                            'name' => "{$name}: {$actionName}",
+                            'module' => $module,
+                            'entity' => $definition['entity'] ?? null,
+                            'field' => $field,
+                            'action' => $action,
+                            'type' => Permission::TYPE_FIELD,
+                            'group' => ($definition['name'] ?? $module) . ' / Поля',
+                            'sort' => $sort++,
+                            'is_system' => true,
+                        ],
+                    );
+                }
+            }
+        }
+
+        return $permissions;
+    }
+
+    private function backfillUserRoles(array $roles): void
+    {
+        foreach ($roles as $slug => $role) {
+            User::query()
+                ->where('role', $slug)
+                ->whereNull('role_id')
+                ->withTrashed()
+                ->update(['role_id' => $role->id]);
+        }
+    }
+
+    private function seedRolePermissions(array $roles, array $permissions): void
+    {
+        $all = array_keys($permissions);
+        $auth = [
+            'orders.view',
+            'orders.photos.upload',
+            'orders.documents.generate',
+            'orders.chat.create',
+            'reclamations.view',
+            'reclamations.photos.upload',
+            'reclamations.chat.create',
+            'schedule.view',
+            'profile.view',
+            'profile.update',
+            'profile.delete',
+            'notifications.view',
+            'notifications.mark_read',
+            'areas.ajax.view',
+            'catalog.search',
+            'pricing_codes.search',
+            'filters.view',
+        ];
+
+        $manager = array_merge($auth, [
+            'orders.update',
+            'orders.export',
+            'orders.documents.upload',
+            'orders.documents.delete',
+            'orders.documents.generate',
+            'orders.photos.delete',
+            'reclamations.create',
+            'reclamations.update',
+            'reclamations.export',
+            'reclamations.status.update',
+            'reclamations.documents.upload',
+            'reclamations.documents.delete',
+            'reclamations.documents.generate',
+            'reclamations.photos.delete',
+            'reclamations.act.upload',
+            'reclamations.act.delete',
+            'reclamations.spare_parts.manage',
+            'catalog.view',
+            'maf.view',
+            'maf.update',
+            'maf.passports.upload',
+            'contracts.view',
+            'contracts.create',
+            'contracts.update',
+            'contracts.delete',
+            'responsibles.view',
+            'responsibles.create',
+            'responsibles.update',
+            'responsibles.delete',
+            'reports.view',
+            'spare_parts.view',
+            'spare_parts.search',
+            'spare_part_orders.view',
+            'spare_part_orders.create',
+            'spare_part_orders.update',
+            'spare_part_orders.ship',
+            'spare_part_orders.stock.manage',
+            'spare_part_reservations.view',
+            'spare_part_reservations.manage',
+            'spare_part_reservations.issue',
+            'spare_part_reservations.cancel',
+            'spare_part_reservations.reassign',
+            'spare_part_inventory.view',
+            'chat_messages.create',
+            'chat_messages.notify',
+        ]);
+
+        $brigadier = array_merge($auth, [
+            'reclamations.act.upload',
+        ]);
+
+        $warehouseHead = array_merge($auth, [
+            'reclamations.act.upload',
+        ]);
+
+        $rolePermissions = [
+            Role::ADMIN => $all,
+            Role::ASSISTANT_HEAD => $all,
+            Role::MANAGER => $manager,
+            Role::BRIGADIER => $brigadier,
+            Role::WAREHOUSE_HEAD => $warehouseHead,
+        ];
+
+        foreach ($rolePermissions as $roleSlug => $permissionSlugs) {
+            $role = $roles[$roleSlug] ?? null;
+            if (!$role) {
+                continue;
+            }
+
+            $sync = [];
+            foreach (array_unique($permissionSlugs) as $permissionSlug) {
+                if (isset($permissions[$permissionSlug])) {
+                    $sync[$permissions[$permissionSlug]->id] = ['effect' => 'allow'];
+                }
+            }
+
+            $role->permissions()->sync($sync);
+        }
+    }
+}

+ 77 - 0
tests/Unit/Services/AccessServiceTest.php

@@ -0,0 +1,77 @@
+<?php
+
+namespace Tests\Unit\Services;
+
+use App\Models\Permission;
+use App\Models\Role;
+use App\Models\User;
+use App\Services\Access\AccessService;
+use Database\Seeders\RbacSeeder;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Tests\TestCase;
+
+class AccessServiceTest extends TestCase
+{
+    use RefreshDatabase;
+
+    public function test_rbac_seeder_backfills_user_role_id(): void
+    {
+        $user = User::factory()->create(['role' => Role::MANAGER]);
+
+        $this->seed(RbacSeeder::class);
+
+        $user->refresh();
+
+        $this->assertNotNull($user->role_id);
+        $this->assertSame(Role::MANAGER, $user->roleModel->slug);
+    }
+
+    public function test_direct_admin_has_all_permissions(): void
+    {
+        $admin = User::factory()->create(['role' => Role::ADMIN]);
+
+        $this->seed(RbacSeeder::class);
+        $admin->refresh();
+
+        $this->assertTrue(app(AccessService::class)->can($admin, 'catalog.delete'));
+        $this->assertTrue(app(AccessService::class)->can($admin, 'catalog.fields.product_price.update'));
+    }
+
+    public function test_manager_has_seeded_permissions_but_not_admin_only_permissions(): void
+    {
+        $manager = User::factory()->create(['role' => Role::MANAGER]);
+
+        $this->seed(RbacSeeder::class);
+        $manager->refresh();
+
+        $this->assertTrue(app(AccessService::class)->can($manager, 'catalog.view'));
+        $this->assertFalse(app(AccessService::class)->can($manager, 'catalog.update'));
+    }
+
+    public function test_user_deny_overrides_role_allow(): void
+    {
+        $manager = User::factory()->create(['role' => Role::MANAGER]);
+
+        $this->seed(RbacSeeder::class);
+        $manager->refresh();
+
+        $permission = Permission::query()->where('slug', 'catalog.view')->firstOrFail();
+        $manager->permissions()->syncWithoutDetaching([
+            $permission->id => ['effect' => 'deny'],
+        ]);
+        app(AccessService::class)->bumpCacheVersion();
+
+        $this->assertFalse(app(AccessService::class)->can($manager, 'catalog.view'));
+    }
+
+    public function test_assistant_head_has_materialized_admin_permissions_without_runtime_inheritance(): void
+    {
+        $assistantHead = User::factory()->create(['role' => Role::ASSISTANT_HEAD]);
+
+        $this->seed(RbacSeeder::class);
+        $assistantHead->refresh();
+
+        $this->assertTrue($assistantHead->hasRole(Role::ADMIN));
+        $this->assertTrue(app(AccessService::class)->can($assistantHead, 'maf_orders.delete'));
+    }
+}