# План внедрения гибкой ролевой модели RBAC + Field-Level ACL ## Цель Внедрить гибкую модель доступа для Stroyprofit CRM: - роли создаются и настраиваются через UI; - пользователю можно назначить роль и индивидуальные переопределения; - доступ проверяется не только к сущности/действию, но и к конкретному полю сущности; - UI, контроллеры, FormRequest, импорт, экспорт, ajax и сервисы используют единый механизм доступа; - существующие проверки `hasRole()` и middleware `role:*` продолжают работать во время миграции. Примеры целевого поведения: - показать каталог, но скрыть цены; - показать каталог, разрешить редактировать продукт, но запретить менять `name_tz`; - разрешить менеджеру видеть рекламации, но скрыть часть полей или запретить конкретные действия; - дать конкретному пользователю дополнительное право без создания отдельной роли; - явно запретить пользователю право, даже если оно есть у роли. Baseline текущего доступа из кода и БД зафиксирован в `docs/flex_roles_access_inventory.md`. Реализация должна использовать его как источник стартовой матрицы permissions для системных ролей. --- ## Текущее состояние кода | Компонент | Текущее состояние | |-----------|-------------------| | Хранение роли | `users.role` string в таблице `users` | | Роли | `app/Models/Role.php` - статический класс с константами | | Текущие роли | `admin`, `manager`, `brigadier`, `warehouse_head`, `assistant_head` | | Legacy-наследование ролей | `Role::effectiveRoles()`: `assistant_head` сейчас получает `assistant_head`, `admin`, `manager` | | Helper | `app/Helpers/roles.php`: `getRoles()`, `hasRole()`, `roleName()` | | Middleware | `EnsureUserHasRole`, alias `role` | | Проверки | `hasRole()` в Blade, контроллерах, FormRequest; role middleware в `routes/web.php` | | Прямые запросы | Есть `User::where('role', ...)` / `whereIn('role', ...)` в уведомлениях, чатах, контроллерах | | UI таблиц | Общий `resources/views/partials/table.blade.php` выводит поля из `$header` без field ACL | | Каталог | Цены выводятся через `product_price_txt`, `installation_price_txt`, `total_price_txt`; редактирование сейчас в основном ограничено `admin` | Важно: целевая модель не должна использовать наследование ролей. Но стартовая матрица прав должна сохранить фактические права всех базовых ролей такими же, как сейчас, включая текущее поведение `assistant_head`. --- ## Основные принципы 1. **Deny имеет приоритет над allow.** Если пользователю или роли явно запрещено право, оно запрещено даже при наличии другого разрешения. 2. **User override сильнее role permissions.** Индивидуальные настройки пользователя применяются поверх роли. 3. **Admin получает все права по умолчанию**, но системные критичные действия всё равно должны иметь защиту от удаления последнего администратора и самоблокировки. 4. **Проверка на сервере обязательна.** Скрытие кнопки или поля в Blade не является защитой. 5. **Полевая модель применяется ко всем каналам:** Blade, таблицы, формы, POST/PUT payload, импорт, экспорт, ajax, генерация документов, API/JSON. 6. **Права должны быть сгруппированы для UI.** Настройка ролей не должна быть плоским списком из сотен чекбоксов. 7. **Обратная совместимость нужна на переходный период.** `hasRole()`, `role:*`, `Role::ADMIN` и старые Blade-проверки нельзя ломать одним шагом. 8. **Наследование ролей не используется в целевой модели.** Для похожих наборов прав используется копирование роли с дальнейшим независимым редактированием. 9. **Роль admin неизменяемая по разрешениям.** У администратора всегда все permissions allow; убирать разрешения, ставить deny или индивидуально запрещать права admin-пользователю нельзя. --- ## Термины | Термин | Пример | Назначение | |--------|--------|------------| | Module | `catalog`, `orders` | Логический модуль CRM | | Entity | `product`, `order` | Сущность доменной модели | | Action permission | `catalog.view`, `catalog.update` | Доступ к действию над модулем/сущностью | | Field permission | `catalog.fields.product_price.view` | Доступ к полю | | Scope | `view`, `create`, `update`, `delete`, `export`, `import` | Тип действия | | Effect | `allow`, `deny` | Разрешение или явный запрет | | Role override | Настройки роли | Базовая матрица доступа | | User override | Настройки пользователя | Исключения для конкретного пользователя | --- ## Архитектура доступа ### 1. Action permissions Права уровня модуля/сущности: ```text catalog.view catalog.create catalog.update catalog.delete catalog.import catalog.export catalog.certificates.upload catalog.certificates.delete orders.view orders.create orders.update orders.delete orders.photos.upload orders.photos.delete orders.documents.generate ``` Action permission отвечает за доступ к маршруту или операции в целом. ### 2. Field permissions Права уровня поля: ```text catalog.fields.article.view catalog.fields.article.update catalog.fields.name_tz.view catalog.fields.name_tz.update catalog.fields.product_price.view catalog.fields.product_price.update catalog.fields.installation_price.view catalog.fields.installation_price.update catalog.fields.total_price.view catalog.fields.total_price.update ``` Field permission отвечает за: - отображение поля в таблице; - отображение поля в карточке/форме; - возможность редактировать поле; - участие поля в фильтрах, сортировке и поиске; - разрешение обновления поля при POST/PUT. Field-level ACL использует только `view` и `update`. Экспорт, импорт и документы регулируются action permissions: если пользователь получил доступ к экспорту/импорту/документу, он получает полный результат этой операции без field masking. ### 3. Effective permissions Итоговые права пользователя вычисляются так: 1. системная роль admin -> all allow, кроме явных защит бизнес-логики; 2. права роли; 3. индивидуальные `user_permissions`; 4. индивидуальные deny перекрывают allow; 5. role deny перекрывает role allow; 6. отсутствие allow означает запрет. Для производительности итоговые права можно кэшировать по ключу: ```text permissions:user:{id}:v{updated_at_hash} ``` Permissions нужно кэшировать. Сброс кэша обязателен при: - изменении роли; - изменении `role_permissions`; - изменении `user_permissions`; - изменении роли пользователя (`users.role_id` или legacy `users.role`); - изменении системного справочника permissions; - запуске сидера RBAC. --- ## База данных ### 1. `roles` ```text id slug unique name description nullable is_system boolean default false is_active boolean default true sort integer default 100 timestamps ``` Системные роли при миграции: - `admin` - `manager` - `brigadier` - `warehouse_head` - `assistant_head` ### 2. `permissions` ```text id slug unique name description nullable module entity nullable field nullable action type enum: action, field group nullable sort integer default 100 is_system boolean default true timestamps ``` Примеры: | slug | module | entity | field | action | type | group | |------|--------|--------|-------|--------|------|-------| | `catalog.view` | catalog | product | null | view | action | Каталог | | `catalog.update` | catalog | product | null | update | action | Каталог | | `catalog.fields.product_price.view` | catalog | product | product_price | view | field | Каталог / Поля | | `catalog.fields.product_price.update` | catalog | product | product_price | update | field | Каталог / Поля | ### 3. `role_permissions` ```text role_id foreign permission_id foreign effect enum: allow, deny timestamps primary(role_id, permission_id) ``` `effect` нужен, чтобы роль могла иметь явный запрет, а не только отсутствие разрешения. ### 4. `user_permissions` ```text user_id foreign permission_id foreign effect enum: allow, deny reason nullable expires_at nullable timestamps primary(user_id, permission_id) ``` `expires_at` необязателен, но полезен для временного доступа. ### 5. `users.role_id` Добавить: ```text role_id foreign nullable ``` На переходный период оставить `users.role`. Миграция: 1. создать роли по текущим slug; 2. заполнить `users.role_id` по `users.role`; 3. сохранить `users.role` для обратной совместимости; 4. позже, отдельной фазой, удалить зависимость кода от `users.role`. ### 6. Наследование ролей Таблица наследования ролей не создаётся. Причины: - источник права становится менее очевидным для администратора; - `allow/deny` на нескольких уровнях сложнее объяснять и тестировать; - при field-level ACL количество комбинаций резко растёт; - тот же результат проще получить копированием роли. В UI нужно добавить действия "Скопировать роль" и "Создать роль из пользователя". Новая роль получает независимую копию разрешений исходной роли или итоговых разрешений выбранного пользователя, после чего редактируется отдельно. Текущее поведение `Role::effectiveRoles()` используется только как источник для миграции стартовой матрицы. После миграции у `assistant_head` должен быть собственный полный набор permissions, эквивалентный текущему фактическому доступу, без runtime-наследования `admin` и `manager`. --- ## Модели и сервисы ### `app/Models/Role.php` Преобразовать в Eloquent model, но сохранить: - `ADMIN` - `MANAGER` - `BRIGADIER` - `WAREHOUSE_HEAD` - `ASSISTANT_HEAD` - `VALID_ROLES` - `NAMES` - `effectiveRoles()` `effectiveRoles()` оставить только для legacy fallback и проверки обратной совместимости. Новая логика доступа не должна строиться на наследовании ролей. Связи: ```php permissions(): BelongsToMany users(): HasMany ``` Методы: ```php hasPermission(string $permission): bool givePermission(string $permission, string $effect = 'allow'): void syncPermissions(array $permissions): void ``` ### `app/Models/Permission.php` Связи: ```php roles(): BelongsToMany users(): BelongsToMany ``` Методы: ```php getGroupedForUi(): Collection actionPermissions(): Builder fieldPermissions(): Builder ``` ### `app/Models/User.php` Добавить: ```php role_id roleModel(): BelongsTo permissions(): BelongsToMany hasRole(string|array $roles): bool hasPermission(string $permission): bool hasAnyPermission(array|string $permissions): bool canViewField(string $module, string $field, ?string $entity = null): bool canUpdateField(string $module, string $field, ?string $entity = null): bool getEffectivePermissions(): Collection ``` `hasRole()` на переходный период: 1. если есть `role_id`, проверять slug роли из БД; 2. fallback на `users.role`; 3. учитывать `Role::effectiveRoles()`. ### `app/Services/Access/AccessService.php` Единая точка принятия решений: ```php can(User $user, string $permission): bool canAny(User $user, array $permissions): bool canViewField(User $user, string $module, string $field, ?string $entity = null): bool canUpdateField(User $user, string $module, string $field, ?string $entity = null): bool filterReadableFields(User $user, string $module, array $fields, ?string $entity = null): array filterWritableData(User $user, string $module, array $data, ?string $entity = null): array assertCan(User $user, string $permission): void ``` Вся логика allow/deny/user override/cache должна жить здесь, а не размазываться по Blade и контроллерам. ### `app/Services/Access/FieldAccessService.php` Можно выделить отдельно, если `AccessService` станет слишком большим: ```php visibleHeaders(User $user, string $module, array $headers): array filterExportColumns(User $user, string $module, array $columns): array filterImportPayload(User $user, string $module, array $row): array filterValidatedPayload(User $user, string $module, array $validated): array ``` --- ## Helpers и Blade ### `app/Helpers/roles.php` Обновить и расширить: ```php getRoles($key = null) hasRole($roles, $user = null): bool roleName($role): string hasPermission(string $permission, $user = null): bool hasAnyPermission(array|string $permissions, $user = null): bool canViewField(string $module, string $field, ?string $entity = null, $user = null): bool canUpdateField(string $module, string $field, ?string $entity = null, $user = null): bool ``` ### Blade directives В `AppServiceProvider`: ```php 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)); ``` Blade используется только для отображения. Серверная проверка всё равно обязательна. --- ## Видимость элементов интерфейса Все элементы интерфейса должны отображаться или скрываться на основе effective permissions текущего пользователя. Проверять нужно не только страницы целиком, но и отдельные элементы: - пункты основного меню; - ссылки в меню пользователя и административном меню; - кнопки создания, редактирования, удаления, восстановления; - кнопки импорта, экспорта, генерации документов; - кнопки загрузки/удаления файлов, фото, сертификатов, паспортов; - вкладки, табы, панели и блоки страницы; - модальные окна и кнопки, которые их открывают; - массовые действия; - inline-действия в строках таблиц; - dropdown/actions меню; - ajax-кнопки смены статуса; - ссылки на карточки сущностей, если у пользователя нет права просмотра; - поля таблиц и формы. Правила: - меню модуля показывается при наличии `{module}.view` или другого права, которое открывает полезный сценарий внутри модуля; - кнопка действия показывается только при наличии соответствующего action permission; - поле формы показывается при наличии `{module}.fields.{field}.view`; - поле формы доступно для редактирования только при наличии `{module}.fields.{field}.update` и action permission на редактирование сущности; - если действие недоступно, предпочтительно скрывать элемент, а не показывать disabled, кроме случаев, где disabled нужен для объяснения состояния; - для `admin` все элементы доступны, кроме ограничений бизнес-логики; - скрытие элемента интерфейса не заменяет middleware, FormRequest, Policy или проверку в сервисе. Для повторяемых элементов желательно использовать общие Blade-компоненты/partials, чтобы проверки не расходились: ```blade @permission('catalog.create') Добавить @endpermission @fieldView('catalog', 'product_price') ... @endfieldView ``` --- ## Middleware ### Обновить `EnsureUserHasRole` Использовать `$request->user()->hasRole($roles)`. ### Новый `EnsureUserHasPermission` Пример: ```php Route::post('catalog/{product}', [ProductController::class, 'update']) ->middleware('permission:catalog.update'); ``` ### Новый `EnsureUserHasAnyPermission` Пример: ```php Route::middleware('permission.any:orders.view,reclamations.view')->group(...); ``` ### Регистрация в `bootstrap/app.php` ```php $middleware->alias([ 'role' => EnsureUserHasRole::class, 'permission' => EnsureUserHasPermission::class, 'permission.any' => EnsureUserHasAnyPermission::class, ]); ``` --- ## UI настройки доступа ### Общие требования UI должен быть удобным при большом числе прав: - группировка по модулям; - внутри модуля отдельные секции: "Действия", "Поля: просмотр", "Поля: редактирование", "Файлы/документы", "Импорт/экспорт"; - поиск по названию права, slug, полю; - быстрые действия: "разрешить всё в модуле", "запретить всё в модуле", "только просмотр", "сбросить"; - три состояния для каждого права: не задано, allow, deny; - визуально показывать источник права: роль или пользовательское переопределение; - предупреждать о конфликте allow/deny; - показывать итоговый effective access для выбранной роли/пользователя. - действие "Скопировать роль" для создания похожей роли без наследования. - действие "Создать роль из пользователя" на основе итоговой совокупности разрешений пользователя. - удаление роли доступно только если к роли не привязан ни один пользователь. - разрешения можно менять у всех ролей, кроме `admin`. ### Структура группировки Пример для каталога: ```text Каталог Действия Просмотр каталога Создание товара Редактирование товара Удаление товара Импорт Экспорт Загрузка сертификата Удаление сертификата Поля: просмотр Артикул Наименование по ТЗ Цена товара Цена установки Итоговая цена Поля: редактирование Артикул Наименование по ТЗ Цена товара Цена установки Итоговая цена ``` ### Управление ролями Контроллер: ```text app/Http/Controllers/Admin/RoleController.php ``` Маршруты: ```php Route::prefix('admin/roles') ->name('admin.roles.') ->middleware('permission:admin.roles') ->group(function () { Route::get('', [RoleController::class, 'index'])->name('index'); Route::get('create', [RoleController::class, 'create'])->name('create'); Route::post('', [RoleController::class, 'store'])->name('store'); Route::get('{role}', [RoleController::class, 'edit'])->name('edit'); Route::put('{role}', [RoleController::class, 'update'])->name('update'); Route::delete('{role}', [RoleController::class, 'destroy'])->name('destroy'); Route::post('{role}/permissions', [RoleController::class, 'syncPermissions'])->name('permissions.sync'); Route::post('{role}/copy', [RoleController::class, 'copy'])->name('copy'); Route::post('from-user/{user}', [RoleController::class, 'storeFromUser'])->name('store-from-user'); }); ``` Views: ```text resources/views/admin/roles/index.blade.php resources/views/admin/roles/edit.blade.php resources/views/admin/roles/partials/permission-matrix.blade.php ``` Правила управления ролями: - `admin` нельзя удалить, деактивировать, переименовать по slug или изменить по permissions; - для `admin` не показывать чекбоксы снятия permissions и deny-состояния, либо показывать их disabled; - для `admin` нельзя сохранять deny в `role_permissions`; - роль можно удалить только если `users.role_id` не содержит ссылок на неё; - перед удалением роли проверять пользователей с учётом soft-deleted записей; - если роль используется, UI должен показать список или количество пользователей, из-за которых удаление недоступно; - системные роли можно защищать от удаления slug, но permissions всех системных ролей, кроме `admin`, можно менять. Копирование роли: - создаёт новую роль с новым `slug` и `name`; - копирует все `role_permissions` исходной роли; - новая роль не связана с исходной; - дальнейшее изменение исходной роли не влияет на копию. Создание роли из пользователя: - берёт `getEffectivePermissions()` выбранного пользователя после применения роли и индивидуальных overrides; - создаёт новую роль с этими permissions как собственными `role_permissions`; - `allow` записывается для всех итогово разрешённых permissions; - итогово запрещённые permissions не записываются, кроме случаев, где для новой роли нужен явный deny; - индивидуальные `user_permissions` исходного пользователя в новую роль не переносятся как связь, а материализуются в матрицу роли; - после создания роли можно назначить её другим пользователям без копирования индивидуальных overrides. ### Управление индивидуальными правами пользователя В форме пользователя добавить вкладки: - "Профиль"; - "Роль"; - "Индивидуальные права"; - "Итоговый доступ". Важно: пользовательские права не заменяют роль, а накладываются поверх неё. Для пользователя с ролью `admin` запрещено создавать индивидуальные deny. Если пользователь является администратором, его итоговый доступ всегда all allow. --- ## Карта модулей и полей Нужен единый источник прав и полей, чтобы не собирать permissions вручную в разных местах. ### Вариант: config/access.php ```php return [ 'catalog' => [ 'name' => 'Каталог', 'entity' => 'product', 'actions' => [ 'view' => 'Просмотр', 'create' => 'Создание', 'update' => 'Редактирование', 'delete' => 'Удаление', 'import' => 'Импорт', 'export' => 'Экспорт', 'certificates.upload' => 'Загрузка сертификата', 'certificates.delete' => 'Удаление сертификата', ], 'fields' => [ 'article' => 'Артикул', 'nomenclature_number' => 'Номер номенклатуры', 'name_tz' => 'Наименование по ТЗ', 'type_tz' => 'Тип по ТЗ', 'manufacturer' => 'Производитель', 'unit' => 'Ед. изм.', 'type' => 'Тип', 'manufacturer_name' => 'Наименование производителя', 'sizes' => 'Размеры', 'product_price' => 'Цена товара', 'installation_price' => 'Цена установки', 'total_price' => 'Итоговая цена', 'note' => 'Примечания', 'certificate_id' => 'Сертификат', 'passport_name' => 'Наименование по паспорту', 'statement_name' => 'Наименование в ведомости', 'service_life' => 'Срок службы', 'certificate_number' => 'Номер сертификата', 'certificate_date' => 'Дата сертификата', 'certificate_issuer' => 'Орган сертификации', 'certificate_type' => 'Вид сертификации', 'weight' => 'Вес', 'volume' => 'Объем', 'places' => 'Мест', ], ], ]; ``` Сидер должен создавать permissions из этого конфига. --- ## Применение field-level ACL в коде ### Меню и действия Переделать существующие Blade-проверки `hasRole()` для меню и action-кнопок на permissions: ```blade @permission('catalog.view') Каталог @endpermission @permission('catalog.export') @endpermission ``` Особое внимание: - `resources/views/layouts/menu.blade.php`; - action toolbar в index/edit views; - `resources/views/partials/table.blade.php` для inline-действий; - модалки импорта/экспорта; - кнопки загрузки и удаления файлов. ### Таблицы Сейчас общий partial таблиц выводит все `$header`. Нужно фильтровать `$header` до передачи во view или внутри helper: ```php $this->data['header'] = app(FieldAccessService::class) ->visibleHeaders($request->user(), 'catalog', $this->data['header']); ``` Для полей с accessor `*_txt` нужна связь с реальным полем: ```text product_price_txt -> product_price installation_price_txt -> installation_price total_price_txt -> total_price ``` ### Формы Для каждого input: - если нет `field.view` - не показывать значение; - если есть `field.view`, но нет `field.update` - показывать readonly/disabled; - если нет `catalog.update` - вся форма readonly; - hidden required-поля не валидируются как required для пользователя без `field.view`; - readonly/disabled-поля не приходят в request или могут быть подделаны, поэтому backend не должен полагаться на disabled; - если create требует поле, которое пользователь не может заполнить, create нужно запретить целиком, кроме случаев с безопасным default. ### FormRequest и сохранение Нельзя просто делать: ```php $product->update($request->validated()); ``` Нужно: ```php $data = app(AccessService::class) ->filterWritableData($request->user(), 'catalog', $request->validated(), 'product'); $product->update($data); ``` Для `create` можно использовать `catalog.fields.*.create` или считать, что `create` использует `update`-права полей. Лучше явно добавить field action `create`, если создание должно отличаться от редактирования. ### Фильтры, сортировка, поиск Если поле скрыто, пользователь не должен: - видеть фильтр по нему; - сортировать по нему; - искать по нему, если поиск раскрывает наличие значения; - получать side-channel через количество результатов. Для каталога это особенно важно для цен. ### Экспорт Экспорт регулируется action permission, например `catalog.export`. Если пользователь имеет право на экспорт, экспорт может содержать полный набор полей для этой операции. Назначение export permission считается ответственностью администратора ролей. ### Импорт Импорт регулируется action permission, например `catalog.import`. Если пользователь имеет право на импорт, импорт может принимать полный набор колонок для этой операции. Назначение import permission считается ответственностью администратора ролей. ### Ajax и search endpoints Проверить маршруты: - `product/search`; - `order-search`; - `pricing-codes/get-description`; - `pricing-codes/search`; - любые endpoint'ы статусов, файлов, чатов. Они должны проверять action permission и не отдавать скрытые поля. ### Документы и генерация файлов Документы и сгенерированные файлы выдаются целиком. Если пользователь имеет action permission на документ, например `orders.documents.generate`, он получает полный документ без маскирования отдельных полей. --- ## Модули и стартовый набор прав Минимальный набор action permissions: | Модуль | Action permissions | |--------|--------------------| | orders | view, create, update, delete, export, documents.generate, documents.upload, documents.delete, photos.upload, photos.delete, maf.manage, ttn.create | | reclamations | view, create, update, delete, export, documents.generate, documents.upload, documents.delete, photos.upload, photos.delete, act.upload, act.delete, spare_parts.manage | | maf | view, update, import, export, passports.upload, passports.delete | | maf_orders | view, create, update, delete, stock.manage | | catalog | view, create, update, delete, import, export, certificates.upload, certificates.delete, thumbnail.upload | | schedule | view, create, update, delete, export | | spare_parts | view, create, update, delete, import, export, image.upload | | spare_part_orders | view, create, update, delete, ship, stock.manage, correct | | spare_part_reservations | view, manage, issue, cancel, reassign | | spare_part_inventory | view | | contracts | view, create, update, delete | | contractors | view, create, update, delete, prices.update, prices.import, prices.export | | users | view, create, update, delete, restore, impersonate, permissions.manage | | responsibles | view, create, update, delete | | reports | view | | import | view, create | | districts | view, create, update, delete, restore, import, export | | areas | view, create, update, delete, restore, import, export | | pricing_codes | view, create, update, delete, search | | notifications | view, mark_read | | admin | settings, clear_data, year_data, roles | Для каждого модуля с табличными данными дополнительно генерируются field permissions: ```text {module}.fields.{field}.view {module}.fields.{field}.update ``` Экспорт, импорт и документы не имеют field-level masking и регулируются action permissions. --- ## Дефолтные роли После применения RBAC все базовые роли должны получить такие же фактические права, как в текущем коде до миграции. Это обязательное требование миграции: пользователь с той же базовой ролью не должен внезапно получить меньше или больше доступа. Стартовая матрица прав формируется не "на глаз", а по аудиту текущих источников доступа: - `routes/web.php` middleware `role:*`; - `hasRole()` в Blade; - `hasRole()` в контроллерах и FormRequest; - `Role::effectiveRoles()`; - прямые выборки `User::where('role', ...)` / `whereIn('role', ...)`; - object-level ограничения в контроллерах. Для спорных мест права фиксируются по текущему фактическому поведению, а изменение бизнес-логики выносится в отдельную задачу после внедрения RBAC. ### Admin - все action permissions allow; - все field permissions allow; - нельзя удалить/деактивировать последнего admin; - нельзя изменить permissions роли `admin`; - нельзя поставить deny роли `admin`; - нельзя снять разрешения с роли `admin`; - нельзя поставить индивидуальный deny пользователю с ролью `admin`; - нельзя снять с самого себя роль `admin`, если это последний активный администратор. ### Manager База должна соответствовать текущему поведению `role:admin,manager`: - orders: view, update, documents/photos where currently allowed, export where route allows manager; - reclamations: view, create, update, export, documents/photos/act according to current routes; - catalog: view; - contracts: view, create, update, delete currently route group allows manager, но нужно проверить бизнес-ожидание; - responsibles: view, create, update, delete; - reports: view; - spare_parts: view; - spare_part_orders: view, create, update, ship, stock.manage; - spare_part_reservations: view, manage; - spare_part_inventory: view; - schedule: view. Поля цен каталога для manager нужно задать явно: - если менеджер должен видеть цены: allow `catalog.fields.*price*.view`; - если нет: deny `catalog.fields.product_price.view`, `installation_price.view`, `total_price.view` и соответствующие `*_txt`. ### Brigadier База по текущему коду: - orders: view, photos.upload, photos.view; - reclamations: view, photos.upload, act.upload; - schedule: view только своих записей, если применяется объектное ограничение. Важно: часть текущих ограничений не role-based, а object-level: бригадир видит свои заказы/рекламации. Это не заменяется RBAC и должно остаться отдельной проверкой. ### Warehouse Head Сейчас используется минимум в рекламациях для upload act и в уведомлениях. Нужно отдельно согласовать набор прав: - reclamations.act.upload; - spare_parts/spare_part_orders/spare_part_inventory по фактической бизнес-роли склада. ### Assistant Head Текущее `effectiveRoles()` даёт `assistant_head` права `admin` и `manager`. В целевой модели наследования нет. Поэтому миграция должна создать для `assistant_head` собственную полную матрицу permissions, которая повторяет текущий фактический доступ этой роли. После этого `assistant_head` редактируется как самостоятельная роль. Если текущий доступ `assistant_head` через admin считается слишком широким, это нужно менять отдельным бизнес-решением после фиксации базовой миграции, а не в рамках технического переноса RBAC. --- ## Object-level ограничения RBAC/field ACL не заменяет ограничения по конкретной записи: - бригадир видит только свои заказы/рекламации; - пользователь не должен редактировать чужой профиль; - нельзя удалить самого себя; - нельзя удалить последнего admin; - soft-deleted пользователи и impersonation имеют отдельные ограничения; - доступ к файлам должен проверять принадлежность файла сущности и право на действие. Для этого нужны Policies: ```text OrderPolicy ReclamationPolicy ProductPolicy SchedulePolicy UserPolicy ``` Policies должны использовать `AccessService`, но также проверять ownership/status/state. --- ## Миграция текущего кода ### Фаза 1. Инфраструктура 1. Создать migrations: `roles`, `permissions`, `role_permissions`, `user_permissions`, `users.role_id`. 2. Создать `Permission` model. 3. Преобразовать `Role` в Eloquent model с сохранением констант и fallback. 4. Добавить методы в `User`. 5. Создать `AccessService` и `FieldAccessService`. 6. Обновить helpers. 7. Добавить middleware `permission` и `permission.any`. 8. Добавить Blade directives. 9. Добавить `config/access.php`. 10. Создать сидер permissions из `config/access.php`. ### Фаза 2. Сидер и обратная совместимость 1. Создать системные роли: все 5 текущих ролей. 2. Заполнить `role_id` у пользователей по `users.role`. 3. Создать стартовую матрицу прав по `docs/flex_roles_access_inventory.md`, полностью повторяющую текущий фактический доступ базовых ролей. 4. Для `assistant_head` развернуть текущее `effectiveRoles()` в собственный набор permissions без наследования. 5. Сохранить `users.role`, `Role::NAMES`, `Role::VALID_ROLES`, `hasRole()`. 6. Добавить тесты, что базовые роли после миграции имеют тот же доступ, что до миграции. ### Фаза 3. UI ролей и пользователей 1. CRUD ролей. 2. Permission matrix с группировкой. 3. Индивидуальные права пользователя. 4. Экран effective access. 5. Кнопка "Скопировать роль" для создания независимой роли на основе существующей. 6. Кнопка "Создать роль из пользователя" на основе effective permissions выбранного пользователя. 7. Удаление роли только при отсутствии привязанных пользователей, включая soft-deleted. 8. Защита системных ролей от удаления slug и критичных изменений. 9. Жёсткая защита permissions роли `admin`: нельзя снять allow, поставить deny или сохранить ограничивающие user overrides для admin-пользователей. ### Фаза 4. Перевод routes/FormRequest 1. Постепенно заменить `role:*` на `permission:*`. 2. FormRequest `authorize()` перевести на permissions. 3. Контроллеры с ручным `hasRole()` перевести на `AccessService`. 4. Прямые `where('role')` заменить на scope/helpers, работающие с `role_id` и fallback. ### Фаза 5. Field-level ACL 1. Начать с `catalog`, потому что там есть явный кейс цен. 2. Фильтровать headers таблиц. 3. Фильтровать поля формы. 4. Фильтровать update payload. 5. Фильтровать фильтры/сортировки/поиск. 6. Проверить action permissions для export/import; field masking для export/import не применяется. 7. Покрыть тестами. 8. Расширить на orders, reclamations, spare_parts, users и остальные модули. ### Фаза 6. Очистка legacy После полного перевода: 1. убрать прямое использование `users.role` из запросов; 2. оставить `users.role` как computed/fallback или удалить отдельной миграцией; 3. заменить `Role::NAMES` на БД/кэш; 4. удалить устаревшие проверки `hasRole()` там, где они больше не нужны. --- ## Особые риски 1. **Скрытие поля только в Blade не защищает данные.** Нужно фильтровать backend payload. 2. **Фильтры и сортировка по скрытым полям раскрывают данные косвенно.** Для цен это критично. 3. **Импорт может обновить скрытые поля.** Это принято как ответственность администратора ролей: import permission даёт полный импорт. 4. **Экспорт может раскрыть скрытые поля.** Это принято как ответственность администратора ролей: export permission даёт полный экспорт. 5. **`assistant_head` сейчас получает права через `effectiveRoles()`.** При переносе нужно развернуть это в явную матрицу permissions, иначе можно случайно расширить или урезать доступ. 6. **Прямые запросы по `users.role` сломают новые роли.** Их нужно заменить. 7. **Кэш permissions может устареть.** Нужна централизованная инвалидация. 8. **Индивидуальные deny могут заблокировать пользователя от UI ролей.** Нужна защита от самоблокировки. 9. **Object-level правила не выражаются простым RBAC.** Нужны Policies/scopes. 10. **Документы могут содержать скрытые поля.** Это принято как all-or-nothing: document action permission отдаёт полный документ. 11. **Удаление роли может оставить пользователей без доступа.** Удалять роль можно только после проверки отсутствия связанных пользователей. 12. **Ограничение admin ломает аварийный доступ.** Для роли `admin` запрещены deny и снятие permissions. --- ## Минимальный MVP Чтобы получить пользу без полного переписывания: 1. Ввести таблицы roles/permissions/user_permissions. 2. Сохранить `users.role` fallback. 3. Реализовать `AccessService`. 4. Сделать UI ролей и индивидуальных прав. 5. Перевести каталог: - `catalog.view/create/update/delete/import/export`; - field ACL `view/update` для всех полей каталога; - особенно `product_price`, `installation_price`, `total_price` и `*_txt`. 6. Защитить `ProductController::store/update`, `StoreProductRequest`, catalog table, export/import. 7. После проверки паттерна переносить остальные модули. --- ## Тесты Нужны feature/unit тесты: - admin имеет все права; - manager видит каталог, но не видит цены при deny; - manager не может обновить `name_tz`, если нет `catalog.fields.name_tz.update`; - скрытое required-поле не валидируется как required для пользователя без `field.view`; - readonly-поле удаляется из update payload при отсутствии `field.update`; - запрещённое поле не попадает в таблицу; - запрещённое поле нельзя использовать в filters/sort/search; - export/import/documents работают по action permission и отдают полный результат операции; - user allow добавляет право поверх роли; - user deny перекрывает role allow; - `hasRole('admin,manager')` работает с `role_id` и fallback `role`; - все 5 базовых ролей после миграции сохраняют текущий фактический доступ; - `assistant_head` получает тот же фактический доступ через собственные permissions, без role inheritance; - копирование роли создаёт независимую роль с теми же permissions; - создание роли из пользователя материализует effective permissions пользователя в новую независимую роль; - роль с привязанными пользователями нельзя удалить; - роль без привязанных пользователей можно удалить; - permissions роли `admin` нельзя изменить; - deny для роли `admin` и admin-пользователя нельзя сохранить; - brigadier object-level ограничения не ломаются. --- ## Команды Запускать внутри контейнеров: ```bash docker compose exec app php artisan migrate docker compose exec app php artisan db:seed --class=RbacSeeder make test ``` --- ## Критерии готовности - роли и права настраиваются через UI; - права сгруппированы по модулям и полям; - есть индивидуальные права пользователя с allow/deny; - есть создание роли копированием существующей роли; - есть создание роли из итоговых разрешений пользователя; - роль удаляется только если к ней не привязан ни один пользователь; - permissions всех ролей, кроме `admin`, можно менять; - у роли `admin` нельзя убрать права или поставить deny; - пункты меню, кнопки, вкладки, модалки, inline-действия и поля UI отображаются по permissions пользователя; - field-level ACL работает для просмотра и редактирования; - каталог может быть показан без цен; - продукт можно редактировать с запретом изменения отдельных полей; - export/import/documents доступны только по соответствующим action permissions и отдают полный результат операции; - legacy role checks не ломают текущий функционал; - прямые проверки `users.role` заменены или имеют совместимый fallback; - основные сценарии покрыты тестами.