Внедрить гибкую модель доступа для Stroyprofit CRM:
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.
hasRole(), role:*, Role::ADMIN и старые Blade-проверки нельзя ломать одним шагом.| Термин | Пример | Назначение |
|---|---|---|
| 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 | Настройки пользователя | Исключения для конкретного пользователя |
Права уровня модуля/сущности:
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 отвечает за доступ к маршруту или операции в целом.
Права уровня поля:
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 отвечает за:
Field-level ACL использует только view и update. Экспорт, импорт и документы регулируются action permissions: если пользователь получил доступ к экспорту/импорту/документу, он получает полный результат этой операции без field masking.
Итоговые права пользователя вычисляются так:
user_permissions;Для производительности итоговые права можно кэшировать по ключу:
permissions:user:{id}:v{updated_at_hash}
Permissions нужно кэшировать. Сброс кэша обязателен при:
role_permissions;user_permissions;users.role_id или legacy users.role);rolesid
slug unique
name
description nullable
is_system boolean default false
is_active boolean default true
sort integer default 100
timestamps
Системные роли при миграции:
adminmanagerbrigadierwarehouse_headassistant_headpermissionsid
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 | Каталог / Поля |
role_permissionsrole_id foreign
permission_id foreign
effect enum: allow, deny
timestamps
primary(role_id, permission_id)
effect нужен, чтобы роль могла иметь явный запрет, а не только отсутствие разрешения.
user_permissionsuser_id foreign
permission_id foreign
effect enum: allow, deny
reason nullable
expires_at nullable
timestamps
primary(user_id, permission_id)
expires_at необязателен, но полезен для временного доступа.
users.role_idДобавить:
role_id foreign nullable
На переходный период оставить users.role.
Миграция:
users.role_id по users.role;users.role для обратной совместимости;users.role.Таблица наследования ролей не создаётся.
Причины:
allow/deny на нескольких уровнях сложнее объяснять и тестировать;В UI нужно добавить действия "Скопировать роль" и "Создать роль из пользователя". Новая роль получает независимую копию разрешений исходной роли или итоговых разрешений выбранного пользователя, после чего редактируется отдельно.
Текущее поведение Role::effectiveRoles() используется только как источник для миграции стартовой матрицы. После миграции у assistant_head должен быть собственный полный набор permissions, эквивалентный текущему фактическому доступу, без runtime-наследования admin и manager.
app/Models/Role.phpПреобразовать в Eloquent model, но сохранить:
ADMINMANAGERBRIGADIERWAREHOUSE_HEADASSISTANT_HEADVALID_ROLESNAMESeffectiveRoles()effectiveRoles() оставить только для legacy fallback и проверки обратной совместимости. Новая логика доступа не должна строиться на наследовании ролей.
Связи:
permissions(): BelongsToMany
users(): HasMany
Методы:
hasPermission(string $permission): bool
givePermission(string $permission, string $effect = 'allow'): void
syncPermissions(array $permissions): void
app/Models/Permission.phpСвязи:
roles(): BelongsToMany
users(): BelongsToMany
Методы:
getGroupedForUi(): Collection
actionPermissions(): Builder
fieldPermissions(): Builder
app/Models/User.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() на переходный период:
role_id, проверять slug роли из БД;users.role;Role::effectiveRoles().app/Services/Access/AccessService.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 станет слишком большим:
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
app/Helpers/roles.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
В AppServiceProvider:
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 текущего пользователя.
Проверять нужно не только страницы целиком, но и отдельные элементы:
Правила:
{module}.view или другого права, которое открывает полезный сценарий внутри модуля;{module}.fields.{field}.view;{module}.fields.{field}.update и action permission на редактирование сущности;admin все элементы доступны, кроме ограничений бизнес-логики;Для повторяемых элементов желательно использовать общие Blade-компоненты/partials, чтобы проверки не расходились:
@permission('catalog.create')
<a href="{{ route('catalog.create') }}" class="btn btn-sm btn-primary">Добавить</a>
@endpermission
@fieldView('catalog', 'product_price')
...
@endfieldView
EnsureUserHasRoleИспользовать $request->user()->hasRole($roles).
EnsureUserHasPermissionПример:
Route::post('catalog/{product}', [ProductController::class, 'update'])
->middleware('permission:catalog.update');
EnsureUserHasAnyPermissionПример:
Route::middleware('permission.any:orders.view,reclamations.view')->group(...);
bootstrap/app.php$middleware->alias([
'role' => EnsureUserHasRole::class,
'permission' => EnsureUserHasPermission::class,
'permission.any' => EnsureUserHasAnyPermission::class,
]);
UI должен быть удобным при большом числе прав:
admin.Пример для каталога:
Каталог
Действия
Просмотр каталога
Создание товара
Редактирование товара
Удаление товара
Импорт
Экспорт
Загрузка сертификата
Удаление сертификата
Поля: просмотр
Артикул
Наименование по ТЗ
Цена товара
Цена установки
Итоговая цена
Поля: редактирование
Артикул
Наименование по ТЗ
Цена товара
Цена установки
Итоговая цена
Контроллер:
app/Http/Controllers/Admin/RoleController.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:
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 не содержит ссылок на неё;admin, можно менять.Копирование роли:
slug и name;role_permissions исходной роли;Создание роли из пользователя:
getEffectivePermissions() выбранного пользователя после применения роли и индивидуальных overrides;role_permissions;allow записывается для всех итогово разрешённых permissions;user_permissions исходного пользователя в новую роль не переносятся как связь, а материализуются в матрицу роли;В форме пользователя добавить вкладки:
Важно: пользовательские права не заменяют роль, а накладываются поверх неё.
Для пользователя с ролью admin запрещено создавать индивидуальные deny. Если пользователь является администратором, его итоговый доступ всегда all allow.
Нужен единый источник прав и полей, чтобы не собирать permissions вручную в разных местах.
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 из этого конфига.
Переделать существующие Blade-проверки hasRole() для меню и action-кнопок на permissions:
@permission('catalog.view')
<a href="{{ route('catalog.index') }}">Каталог</a>
@endpermission
@permission('catalog.export')
<button type="button" data-bs-toggle="modal" data-bs-target="#exportModal">Экспорт</button>
@endpermission
Особое внимание:
resources/views/layouts/menu.blade.php;resources/views/partials/table.blade.php для inline-действий;Сейчас общий partial таблиц выводит все $header. Нужно фильтровать $header до передачи во view или внутри helper:
$this->data['header'] = app(FieldAccessService::class)
->visibleHeaders($request->user(), 'catalog', $this->data['header']);
Для полей с accessor *_txt нужна связь с реальным полем:
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;field.view;Нельзя просто делать:
$product->update($request->validated());
Нужно:
$data = app(AccessService::class)
->filterWritableData($request->user(), 'catalog', $request->validated(), 'product');
$product->update($data);
Для create можно использовать catalog.fields.*.create или считать, что create использует update-права полей. Лучше явно добавить field action create, если создание должно отличаться от редактирования.
Если поле скрыто, пользователь не должен:
Для каталога это особенно важно для цен.
Экспорт регулируется action permission, например catalog.export. Если пользователь имеет право на экспорт, экспорт может содержать полный набор полей для этой операции. Назначение export permission считается ответственностью администратора ролей.
Импорт регулируется action permission, например catalog.import. Если пользователь имеет право на импорт, импорт может принимать полный набор колонок для этой операции. Назначение import permission считается ответственностью администратора ролей.
Проверить маршруты:
product/search;order-search;pricing-codes/get-description;pricing-codes/search;Они должны проверять 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:
{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', ...);Для спорных мест права фиксируются по текущему фактическому поведению, а изменение бизнес-логики выносится в отдельную задачу после внедрения RBAC.
admin;admin;admin;admin;admin, если это последний активный администратор.База должна соответствовать текущему поведению role:admin,manager:
Поля цен каталога для manager нужно задать явно:
catalog.fields.*price*.view;catalog.fields.product_price.view, installation_price.view, total_price.view и соответствующие *_txt.База по текущему коду:
Важно: часть текущих ограничений не role-based, а object-level: бригадир видит свои заказы/рекламации. Это не заменяется RBAC и должно остаться отдельной проверкой.
Сейчас используется минимум в рекламациях для upload act и в уведомлениях. Нужно отдельно согласовать набор прав:
Текущее effectiveRoles() даёт assistant_head права admin и manager.
В целевой модели наследования нет. Поэтому миграция должна создать для assistant_head собственную полную матрицу permissions, которая повторяет текущий фактический доступ этой роли. После этого assistant_head редактируется как самостоятельная роль.
Если текущий доступ assistant_head через admin считается слишком широким, это нужно менять отдельным бизнес-решением после фиксации базовой миграции, а не в рамках технического переноса RBAC.
RBAC/field ACL не заменяет ограничения по конкретной записи:
Для этого нужны Policies:
OrderPolicy
ReclamationPolicy
ProductPolicy
SchedulePolicy
UserPolicy
Policies должны использовать AccessService, но также проверять ownership/status/state.
roles, permissions, role_permissions, user_permissions, users.role_id.Permission model.Role в Eloquent model с сохранением констант и fallback.User.AccessService и FieldAccessService.permission и permission.any.config/access.php.config/access.php.role_id у пользователей по users.role.docs/flex_roles_access_inventory.md, полностью повторяющую текущий фактический доступ базовых ролей.assistant_head развернуть текущее effectiveRoles() в собственный набор permissions без наследования.users.role, Role::NAMES, Role::VALID_ROLES, hasRole().admin: нельзя снять allow, поставить deny или сохранить ограничивающие user overrides для admin-пользователей.role:* на permission:*.authorize() перевести на permissions.hasRole() перевести на AccessService.where('role') заменить на scope/helpers, работающие с role_id и fallback.catalog, потому что там есть явный кейс цен.После полного перевода:
users.role из запросов;users.role как computed/fallback или удалить отдельной миграцией;Role::NAMES на БД/кэш;hasRole() там, где они больше не нужны.assistant_head сейчас получает права через effectiveRoles(). При переносе нужно развернуть это в явную матрицу permissions, иначе можно случайно расширить или урезать доступ.users.role сломают новые роли. Их нужно заменить.admin запрещены deny и снятие permissions.Чтобы получить пользу без полного переписывания:
users.role fallback.AccessService.catalog.view/create/update/delete/import/export;view/update для всех полей каталога;product_price, installation_price, total_price и *_txt.ProductController::store/update, StoreProductRequest, catalog table, export/import.Нужны feature/unit тесты:
name_tz, если нет catalog.fields.name_tz.update;field.view;field.update;hasRole('admin,manager') работает с role_id и fallback role;assistant_head получает тот же фактический доступ через собственные permissions, без role inheritance;admin нельзя изменить;admin и admin-пользователя нельзя сохранить;Запускать внутри контейнеров:
docker compose exec app php artisan migrate
docker compose exec app php artisan db:seed --class=RbacSeeder
make test
admin, можно менять;admin нельзя убрать права или поставить deny;users.role заменены или имеют совместимый fallback;