flex_roles_plan.md 52 KB

План внедрения гибкой ролевой модели 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

Права уровня модуля/сущности:

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

Права уровня поля:

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 означает запрет.

Для производительности итоговые права можно кэшировать по ключу:

permissions:user:{id}:v{updated_at_hash}

Permissions нужно кэшировать. Сброс кэша обязателен при:

  • изменении роли;
  • изменении role_permissions;
  • изменении user_permissions;
  • изменении роли пользователя (users.role_id или legacy users.role);
  • изменении системного справочника permissions;
  • запуске сидера RBAC.

База данных

1. roles

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

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

role_id foreign
permission_id foreign
effect enum: allow, deny
timestamps
primary(role_id, permission_id)

effect нужен, чтобы роль могла иметь явный запрет, а не только отсутствие разрешения.

4. user_permissions

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

Добавить:

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 и проверки обратной совместимости. Новая логика доступа не должна строиться на наследовании ролей.

Связи:

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() на переходный период:

  1. если есть role_id, проверять slug роли из БД;
  2. fallback на users.role;
  3. учитывать 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

Helpers и Blade

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

Blade directives

В 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 текущего пользователя.

Проверять нужно не только страницы целиком, но и отдельные элементы:

  • пункты основного меню;
  • ссылки в меню пользователя и административном меню;
  • кнопки создания, редактирования, удаления, восстановления;
  • кнопки импорта, экспорта, генерации документов;
  • кнопки загрузки/удаления файлов, фото, сертификатов, паспортов;
  • вкладки, табы, панели и блоки страницы;
  • модальные окна и кнопки, которые их открывают;
  • массовые действия;
  • 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, чтобы проверки не расходились:

@permission('catalog.create')
    <a href="{{ route('catalog.create') }}" class="btn btn-sm btn-primary">Добавить</a>
@endpermission

@fieldView('catalog', 'product_price')
    ...
@endfieldView

Middleware

Обновить 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 настройки доступа

Общие требования

UI должен быть удобным при большом числе прав:

  • группировка по модулям;
  • внутри модуля отдельные секции: "Действия", "Поля: просмотр", "Поля: редактирование", "Файлы/документы", "Импорт/экспорт";
  • поиск по названию права, slug, полю;
  • быстрые действия: "разрешить всё в модуле", "запретить всё в модуле", "только просмотр", "сбросить";
  • три состояния для каждого права: не задано, allow, deny;
  • визуально показывать источник права: роль или пользовательское переопределение;
  • предупреждать о конфликте allow/deny;
  • показывать итоговый effective access для выбранной роли/пользователя.
  • действие "Скопировать роль" для создания похожей роли без наследования.
  • действие "Создать роль из пользователя" на основе итоговой совокупности разрешений пользователя.
  • удаление роли доступно только если к роли не привязан ни один пользователь.
  • разрешения можно менять у всех ролей, кроме 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 не содержит ссылок на неё;
  • перед удалением роли проверять пользователей с учётом 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

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:

@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;
  • action toolbar в index/edit views;
  • 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;
  • hidden required-поля не валидируются как required для пользователя без field.view;
  • readonly/disabled-поля не приходят в request или могут быть подделаны, поэтому backend не должен полагаться на disabled;
  • если create требует поле, которое пользователь не может заполнить, create нужно запретить целиком, кроме случаев с безопасным default.

FormRequest и сохранение

Нельзя просто делать:

$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, если создание должно отличаться от редактирования.

Фильтры, сортировка, поиск

Если поле скрыто, пользователь не должен:

  • видеть фильтр по нему;
  • сортировать по нему;
  • искать по нему, если поиск раскрывает наличие значения;
  • получать 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:

{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:

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 ограничения не ломаются.

Команды

Запускать внутри контейнеров:

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;
  • основные сценарии покрыты тестами.