Матрица прав RMS
Матрица прав — источник истины о том, кто какие объекты видит и правит. Она пришла из спецификации «Структура RMS» (п.9, приложение «Матрица_прав_RMS.xlsx») и живёт в коде как internal/permissions/matrix.go: 16 объектов × 11 ролей. Старые permission-коды (projects.manage, finance.manage и т.д.) остались техническим слоем middleware и fallback-ом для ролей, заведённых до матрицы.
Реализовано:
internal/permissions,internal/transport/permissions.go,internal/modules/rbac/service.go(RequireObjectAccess/RequireObjectWrite),internal/authorization/access.go, миграции0130(роли) и0137(коды прав для ролей).
Смежные страницы: Security & RBAC (JWT, Casbin, оргструктура), docs/rbac_matrix.md (permission-коды по маршрутам).
Как читать матрицу
Роли пользователя (user_roles)
↓ NormalizeRole: код спецификации или legacy-алиас
Столбцы матрицы → по каждому объекту максимум отдельно по чтению и по записи
↓
permissions.Set { object → {read: none|own|all, write: none|own|all} }
↓
middleware (есть ли доступ вообще) → сервис (сужение до «своего») → Redact (вырезание полей)Символы
| Символ | В xlsx | Access | Смысл |
|---|---|---|---|
F | П | read=all, write=all | Полный доступ ко всем объектам |
R | Р | read=all, write=none | Читать всё, не править |
O | С | read=own, write=own | Только свой дивизион / свои проекты |
- | — | read=none, write=none | Доступа нет |
Охваты упорядочены: none < own < all. Разделение на две оси нужно, потому что «Р» и «С» не сводятся к одной шкале: у пользователя с обеими ролями доступ складывается как чтение всего плюс запись в своём дивизионе.
Роли
| Код | Назначение (из migrations/0130) |
|---|---|
OWNER | Владелец. Полный доступ ко всему, включая права пользователей |
OPS | Операционный директор. Все проекты и справочники, настройка процессов, права пользователей |
FIN_DIR | Финансовый директор. Полный доступ к финансовому контуру |
FIN_ASSIST | Ассистент финансового директора. Реестр обязательств и постоянные платежи |
LEGAL | Юридический отдел. Договоры, счета и закрывающие документы. Без себестоимости и маржи |
TECH_DIR | Технический директор. Ведение проектов по всем дивизионам. Без заработка и маржи |
DIV_HEAD | Руководитель дивизиона. Проекты своего дивизиона целиком, чужие — ограниченно |
MANAGER | Менеджер проектов. Ведение проектов своего дивизиона. Без заработка и маржи |
ENGINEER | Инженер. Свой табель, без финансовой части проектов |
DIV_STAFF | Сотрудник дивизиона. Свои задачи, без финансовой части |
WAREHOUSE | Склад. Только наличие и движение оборудования |
Полная матрица
Столбцы идут в порядке исходного xlsx (roleOrder), строки — в порядке matrixRows.
| Объект | OWNER | OPS | FIN_DIR | FIN_ASSIST | LEGAL | TECH_DIR | DIV_HEAD | MANAGER | ENGINEER | DIV_STAFF | WAREHOUSE |
|---|---|---|---|---|---|---|---|---|---|---|---|
project_card — карточка проекта | F | F | R | R | R | F | O | O | R | R | - |
project_status_dates — статус и даты | F | F | R | R | R | F | O | O | R | - | - |
client_budget — бюджет клиента (сумма сделки, вероятность закрытия) | F | F | F | F | R | R | O | O | - | - | - |
costs — затраты / себестоимость | F | F | F | R | - | R | O | O | - | - | - |
earnings_margin — заработок и маржа | F | F | F | - | - | - | - | - | - | - | - |
probability_weighted — вероятность и взвешенный заработок | F | F | R | - | - | - | - | - | - | - | - |
contracts — договоры и документы | F | R | F | F | F | R | - | R | - | - | - |
obligations — реестр обязательств | F | R | F | F | R | - | - | - | - | - | - |
recurring_payments — постоянные платежи | F | R | F | F | - | - | - | - | - | - | - |
departments_directory — справочник дивизионов | F | F | - | - | - | - | - | - | - | - | - |
counterparties_directory — контрагенты | F | F | F | F | F | R | F | F | - | - | - |
equipment_stock — оборудование и склад | F | F | - | - | - | R | R | R | R | R | F |
users_and_rights — пользователи и права | F | F | - | - | - | - | - | - | - | - | - |
reports_dashboards — отчёты и дашборд | F | F | F | R | - | O | O | O | - | - | - |
own_timesheet — свой табель | F | F | F | F | F | F | F | F | F | F | F |
others_timesheets — чужие табели | F | F | R | R | - | F | O | - | - | - | - |
Два объекта заведены в матрице, но отдельной проверки в коде пока не имеют: own_timesheet (у всех F, свой табель правами не гейтится) и project_status_dates (статус и даты сейчас идут вместе с project_card).
Как считаются права пользователя
Роли складываются.
permissions.Resolve(roles)берёт по каждому объекту максимум отдельно по чтению и по записи. Сотрудник с ролямиMANAGER+FIN_ASSISTполучитproject_card: read=all (от FIN_ASSIST), write=own (от MANAGER).Глобальный админ получает всё.
permissions.FromRoles(roles, globalAdmin)возвращаетFull()(все объектыF), если у пользователя есть permission-кодsystem.global_admin. Матрица введена поверх старой модели и не должна сужать уже выданные права.OWNERполучает этот код миграцией0137,adminимеет его с0061.Раздел открывает галочка в роли.
permissions.ForUser(roles, codes, globalAdmin)смотрит итоговые коды пользователя (после личных запретов): строка матрицы задаёт охват, а раздел включает только его галочка в редакторе роли (sectionGates). Без кода чтения охват чтения сужается, без кода записи — охват записи:Объекты Галочка: чтение Галочка: запись Без галочки project_card,project_status_datesprojects.view,projects.manageprojects.manage«свой» (проекты, где участвует) client_budget,costs,earnings_margin,probability_weighted,obligations,recurring_payments«Финансы» finance.manage,accounting.*,obligations.*те же «свой» (деньги своих проектов) contractslegal.view,legal.manage,documents.managelegal.manage,documents.manageнет counterparties_directorycompanies.*,contacts.*,clients.*(.viewили.manage).manageнет equipment_stockequipment.view,equipment.manageequipment.manageнет departments_directorydepartments.view,departments.manage,org.managedepartments.manage,org.manageнет users_and_rightsusers.view,users.manageusers.manageнет reports_dashboardsdashboard.viewdashboard.viewнет others_timesheetstimesheets.read_all,timesheets.approvetimesheets.approve«свой» (подчинённые) Так снятая в роли галочка и личный запрет действительно закрывают раздел, а редактор роли показывает правду: например,
TECH_DIRбез «Финансов» реестр документов, обзор финансов и платежи всей компании не видит. Коды кладёт в контекст каждого запросаrbac.LoadPermissions(проверка после аутентификации); без загруженных кодов (внутренние вызовы) матрица — как есть. Миграция0193завела коды «просмотр» (projects.view,companies.view,contacts.view,clients.view,equipment.view,departments.view,users.view) и проставила ролям галочки по их строке матрицы — после выката каждый видит то же, что и раньше (деньги не сидировались). Галочка «просмотр» открывает чтение и у роли вне матрицы:RequireObjectAccessна чтение принимаетx.viewвместо fallback-кодаx.manage. Реестр документов (ReadsDocumentsRegistry) поlegal.viewне открывается — этот код открывает только «Договоры».Неизвестные роли прав не дают.
NormalizeRoleвозвращает пустую строку — такая роль пропускается.Список ролей берётся из контекста запроса (
user_roles, заполняет auth middleware); если списка нет — из основной роли токена (user_role).
Legacy-алиасы ролей
Роли, заведённые до спецификации, отображаются на ближайшую роль матрицы (legacyRoleAliases), чтобы существующие пользователи не потеряли доступ при выкате:
| Legacy-роль | Роль матрицы |
|---|---|
admin, owner | OWNER |
director | OPS |
head_of_department | DIV_HEAD |
manager, project_manager, pm | MANAGER |
engineer | ENGINEER |
employee, user | DIV_STAFF |
accountant | FIN_ASSIST |
legal, lawyer | LEGAL |
warehouse, logist, logistician | WAREHOUSE |
Сравнение регистронезависимое: Manager, MANAGER и manager — одно и то же.
Что значит «свой» (own)
Охват own сужается уже в сервисе — middleware отвечает только за «есть ли доступ вообще». «Своим» считается объединение трёх множеств:
| Источник | Откуда берётся | Где применяется |
|---|---|---|
| Дивизионы пользователя | user_departments → user_department_ids в контексте (плюс основной users.department_id) | Проекты, задачи, табели, записи времени, разделы проекта |
| Дивизионы проекта | projects.department_id или любая строка project_departments (проект может идти по нескольким дивизионам, миграция 0132) | Actor.inDivisions, divisionClause в authorization/access.go |
| Личное участие | менеджер проекта (manager_id), активный участник project_team_members, автор / исполнитель / соисполнитель задачи | Список и карточка проекта, задачи, чаты, файлы |
Для чужих табелей и записей времени «свой» — это сотрудник, состоящий хотя бы в одном из дивизионов проверяющего (user_departments).
Middleware: RequireObjectAccess и RequireObjectWrite
internal/modules/rbac/service.go:
RequireObjectAccess(object, fallbackCodes...)— безопасные методы (GET,HEAD,OPTIONS) требуютreadобъекта хотя бы в охватеown, остальные методы —write.RequireObjectWrite(object, fallbackCodes...)— то же, но чтение открыто любому аутентифицированному пользователю. Нужен справочникам, которые все читают в формах, а правят только владельцы объекта.RequireObjectRead(object, fallbackCodes...)— наоборот: чтение требует объекта, а запись только загружает права и уходит в обработчик, который проверяет конкретную сущность. Так закрыта команда проекта: её ведёт и руководитель проекта с ролью «только чтение».
Порядок проверки внутри middleware:
1. RequireObjectWrite и метод чтения → allow
2. system.global_admin → allow
3. system.global_read и метод чтения → allow
4. матрица: CanRead(object) / CanWrite(object)
5. fallback: любой из legacy permission-кодов
6. иначе 403 forbiddenFallback-коды — коды старой модели, они сохраняют доступ ролям, заведённым до матрицы (например, logistician с equipment.manage).
Справочники отделов и локаций читают все
GET /api/v1/departments и GET /api/v1/locations закрыты через RequireObjectWrite, поэтому доступны любому аутентифицированному пользователю, хотя в матрице строка departments_directory — F только у OWNER/OPS, а equipment_stock закрыт для FIN_DIR, FIN_ASSIST, LEGAL. Причина: без списка отделов нельзя выбрать дивизион в карточке проекта или сотрудника, без локаций — площадку проекта или командировки. Запись (POST/PUT/DELETE) по-прежнему требует write объекта.
Группы маршрутов → объект матрицы
Сводка по блоку middleware в internal/app/app.go (NewRouter). Столбец «Fallback» — legacy-коды, которые сохраняют доступ.
| Маршруты | Объект | Middleware | Fallback |
|---|---|---|---|
/users (создание, админ-карточка, изменение), /rbac/* | users_and_rights | RequireObjectAccess | users.manage |
/settings/my-company/requisites, /my-company/requisites | — (читают все; запись — финансы, см. «Каждый отвечает за своё») | AttachPermissions + проверка в обработчике | — |
/org/* (изменение иерархии, должности) | users_and_rights | RequireObjectAccess | org.manage |
/departments | departments_directory | RequireObjectWrite (чтение всем) | departments.manage |
/companies, /companies/:id/bank-accounts, /companies/:id/contacts, /requisites | counterparties_directory | RequireObjectAccess | companies.manage |
/contacts, /contacts/:id/companies | counterparties_directory | RequireObjectAccess | contacts.manage |
/locations | equipment_stock | RequireObjectWrite (чтение всем) | locations.manage |
/equipment | equipment_stock | RequireObjectAccess | equipment.manage |
/suppliers | equipment_stock | RequireObjectAccess | suppliers.manage |
/finance/* | costs | RequireObjectAccess | finance.manage |
/projects/:id/budget/* | client_budget или costs (чтение — чтение любого, запись настроек — запись любого) | budget.RequireBudgetAccess; проект — раздел finance | finance.manage |
/accounts, /payments, /counterparties/*, платёжные /reports/* | costs (охват «свой» сужает сервис, см. «Касса и платежи») | RequireObjectAccess | accounting.view (GET), accounting.manage |
/trips | project_card (PATCH — RequireObjectRead: решает обработчик, см. «Командировки») | RequireObjectAccess | trips.manage |
/project-team | project_card | RequireObjectRead: чтение — по объекту, запись решает обработчик по проекту (см. «Команда проекта») | project_team.manage |
/dashboard, /kpi, /reports/* | reports_dashboards | RequireObjectAccess | dashboard.view |
/document-templates | contracts (чтение — любой читающий договоры, запись — только documents.manage) | RequireObjectAccess | documents.manage |
/contracts/* | contracts | RequireObjectAccess | legal.view (GET), legal.manage |
/compensation-profiles, /payroll/* | earnings_margin | RequireObjectAccess | payroll.view (GET), payroll.manage |
/obligations | obligations / recurring_payments | проверка в handler (requireRead/requireWrite) | — |
/timesheets (сводный, чужой, review) | others_timesheets | проверка в handler | — |
/time-entries (GET) | others_timesheets (сужение выборки в обработчике) | только аутентификация | — |
/absences, /calendar | others_timesheets (чтение — охват чужих отсутствий), users_and_rights (запись — заводит за любого, календарь и настройки) | AttachPermissions; руководитель и генеральный директор — по оргструктуре и настройкам в сервисе | — |
/time-entries (POST) | — | RequirePermissionsOrDepartmentManager | time_entries.manage |
/projects, /tasks, /team-requests, /projects/:id/documents/*, /equipment-requests, /logistics, /search, /audit, /entities/*/timeline | project_card и разделы проекта | только AttachPermissions; охват решает сервис | — |
/files | — | legacy files.read / files.upload / files.delete / files.manage | — |
/chats, /messages, /ws/chat | — | legacy chat.read / chat.write / chat.manage_participants / chat.react / chat.ping | — |
/org/tree, /org/positions, /org/users/:id/* (чтение) | — | legacy org.view / org.manage | — |
/nda/report, POST /nda/versions | — | legacy nda.report / nda.manage | — |
Файлы, чаты, чтение оргструктуры и NDA пока остаются на legacy-кодах; миграция 0137 выдаёт их всем 11 ролям (org.view, files.read, files.upload, chat.*), а files.delete и chat.manage_participants — ролям с записью в project_card. Миграция 0147 выдаёт org.view и legacy-ролям (engineer, head_of_department, employee, accountant, lawyer, warehouse, logist, logistician, director, user) — оргструктуру видят все — и снимает с legacy head_of_department код departments.manage (у DIV_HEAD справочник отделов «-»).
Уровень сервиса: как охват сужается
Проекты (internal/modules/projects/service.go)
Actor хранит Perms (матрицу по всем ролям) и вспомогательные методы:
| Метод | Условие |
|---|---|
canReadAll() | глобальный доступ, HasGlobalRead или project_card.read = all |
hasDivisionScope() | project_card.read = own |
canWriteAll() | глобальный доступ или project_card.write = all |
canWriteOwn() | project_card.write = own |
inDivisions(p) | проект относится хотя бы к одному дивизиону актора (department_id или department_ids) |
Правила:
List:canReadAll→ все проекты; иначе руководитель отдела продаж → все; иначе приhasDivisionScopeв фильтр ставятсяScopeDepartmentIDs(дивизионы актора) иScopeUserID— выборка «проекты моих дивизионов ИЛИ проекты, где я участвую лично»; иначе только личное участие (ParticipantUserID).Get:canReadAll→ ок; отдел продаж → ок;hasDivisionScope && inDivisions→ ок (иначе Casbinprojects/readпо домену отдела); иначе требуется личное участие (менеджер, команда, задачи).Create:canWriteAll— любой дивизион;canWriteOwn— только в своём (department_idиз запроса должен совпадать); иначе Casbinprojects/write.canManageProject(update, статус, удаление, спецификации):canWriteAll→ ок;canWriteOwn && inDivisions→ ок; руководитель отдела продаж → ок; Casbinprojects/write; иначе только менеджер проекта.canApproveSpec: глобальный доступ,admin, менеджер проекта,canWriteAll,canWriteOwn && inDivisions.Redact(redact.go): перед сериализацией ответа вырезаютсяclose_probabilityиeffective_close_probabilityбезprobability_weighted.readи без записиclient_budget(кто задаёт вероятность, тот её и видит),planned_budgetиactual_revenueбезclient_budget.read. Работает в карточке и списке; выгрузка Excel показывает вероятность по-прежнему только сprobability_weighted.read. Взвешенный заработок в дашборде и отчётах — толькоprobability_weighted.- Деньги проекта (
canSetDealTerms,canSetRevenue, см. «Каждый отвечает за своё»): сумма сделки и вероятность — записьclient_budget, фактическая выручка — затраты «все».
Задачи (internal/modules/tasks/service.go)
Карточка задачи (GET /tasks/:id) отдаёт can_edit (правка: canManage — руководитель проекта, ведущий карточку проекта) и can_work (статус, старт, пауза, завершение: canWorkOnTask — плюс исполнитель и соисполнители); список — только can_work. Интерфейс по ним прячет «Редактировать» и блокирует статус и перетаскивание на канбане.
Файлы задачи (/tasks/:id/files): прикладывают участники задачи (автор, исполнитель, соисполнители, команда проекта — CanAccessEntity), запись карточки «все» и «свой» в проектах своего дивизиона; удаляют (нужен ещё files.delete) те же ведущие и загрузивший файл участник — только свой файл. Сохранение вложения из чата в задачу или проект (save-file) проверяется тем же правилом: актор собирается files.ContextActor с матрицей и system.global_admin.
canReadAll()= глобальный доступ,HasGlobalReadилиproject_card.write = all(OWNER,OPS,TECH_DIR). Чтение карточки (Rу инженера, сотрудника, финансов) задач не открывает: такие роли видят только задачи, где участвуют лично, и задачи проектов, где участвуют.canManageProjectCard(p):project_card.write = all, либоwrite = ownи проект в дивизионах актора — тогда внутри проекта видны все его задачи.- Список без
canReadAllи без полного доступа к проекту сужаетсяVisibleToUserID(автор, исполнитель, соисполнитель, подчинённые по оргструктуре).
Разделы проекта (internal/authorization/access.go)
CanAccessProjectSection сначала смотрит объект матрицы, который открывает раздел целиком, и только потом проверяет участие в команде и проектные роли:
| Раздел | Объект матрицы | Кто проходит без участия в проекте |
|---|---|---|
finance | costs | costs.read = all (OWNER, OPS, FIN_DIR, FIN_ASSIST; у остальных — только при включённом разделе «Финансы», см. выше) |
documents | contracts | contracts.read = all |
logistics | equipment_stock | equipment_stock.read = all |
GET /api/v1/projects/:id отдаёт результат этих проверок для текущего пользователя в поле access:
"access": { "finance": true, "documents": false, "logistics": true }Карточка проекта по нему прячет вкладки и блоки, в которые сервер всё равно не пустит, и не делает запросы, заранее обречённые на 403.
access.chat — открыт ли чат проекта (менеджер, активные участники команды, руководитель продаж; chats.Service.CanAccessProjectChat). Без него вкладка «Чат» карточки скрыта.
access.tasks — видит ли пользователь задачи проекта (та же проверка, что у GET /projects/:id/tasks, tasks.Service.projectTasksVisibility): чтение карточки само задач не открывает. Без него карточка не показывает вкладку «Задачи» и блок задач в «Обзоре».
Документы проекта (/projects/:id/documents/*, GET /documents?project_id=, GET /documents/:id/download, PATCH /documents/:id/status) открыты тем же разделом documents, что и файлы проекта (DocumentsHandler.ensureProjectDocuments → CanAccessProjectSection). Раньше актор документов получал глобальный доступ за legacy-код projects.manage, и руководитель дивизиона (contracts «—») видел, скачивал и формировал документы любого проекта. Теперь projects.manage доступа к документам не даёт; documents.manage (роли с contracts «П») остаётся правом формировать документы и менять их статус — внутри проекта, чей раздел открыт. PDF документа скачивается по тому же разделу, отдельного права на файлы проекта не нужно. Список без project_id — только глобальному доступу и contracts «все».
Файлы проекта (GET /projects/:id/files, /folders) открыты по разделу documents: в папках лежат договоры и коммерческие предложения. Поэтому без access.documents вкладка «Файлы и документы» скрыта целиком, а файл ТЗ скачать нельзя. Внутри раздела чтение файлов проекта даёт project_card.read = all, загрузку и удаление — project_card.write = all, а охват write = own (руководитель дивизиона, менеджер) — чтение, загрузку и удаление в проектах своих дивизионов (department_id или project_departments) и в проектах, где пользователь руководитель проекта (files.Service.authorize, EntityAdapter.InProjectScope). Раньше охват «свой» открывал только запись: менеджер дивизиона загружал файл в проект коллеги, а список файлов того же проекта получал 403. Casbin ролей спецификации не знает, поэтому раньше охват «свой» на файлах не работал.
Действия с карточкой GET /api/v1/projects/:id тоже отдаёт в access — теми же проверками, что у самих действий (матрица не знает ни дивизиона проекта, ни его руководителя):
| Поле | Действие | Проверка |
|---|---|---|
edit | правка карточки и статуса (PUT /projects/:id) | canUpdateProject: запись «все» или «свой» с основным дивизионом проекта в дивизионах пользователя; архивный проект — false |
manage | ТЗ, смета, заявки в команду, архив | canManageProject: плюс руководитель проекта с любой ролью |
files | загрузка и удаление файлов проекта | documents и запись карточки: «все» или «свой» (свой дивизион, свой проект) |
money | сумма сделки и вероятность закрытия | Actor.canSetDealTerms: запись client_budget «все» или «свой» (свой дивизион, свой проект); архивный проект — false |
revenue | фактическая выручка | Actor.canSetRevenue: затраты «все» или глобальный доступ |
equipment_requests | заявки на оборудование: создать, править, отменить, импортировать | склад и логистика; ведение проекта; активный участник команды (internal/app/project_supply_access.go) |
logistics_create | перевозка по проекту (POST /logistics) | склад и логистика; ведение проекта |
Поля нет, если проверка не удалась (ошибка БД) — тогда фронт считает разделы открытыми и полагается на ответы самих разделов. В списке GET /api/v1/projects поля access нет: проверка идёт по каждому проекту и нужна только карточке.
При read = own в SQL добавляется divisionClause: проект принадлежит дивизиону актора по department_id или через project_departments. CanAccessProject аналогично пропускает всех с project_card.read = all, а для own подмешивает дивизионы к условию участия.
Табели и записи времени
others_timesheets определяет доступ к чужим часам (internal/modules/timesheets/transport_http.go, internal/modules/timeentries/transport_http.go):
| Охват | Сводный табель, чужой табель | GET /time-entries |
|---|---|---|
all | все сотрудники | без ограничений |
own | сотрудники из user_departments проверяющего | DepartmentIDs = дивизионы актора |
none | 403 timesheets_foreign_forbidden | выборка сужается до user_id = я |
Утверждение (POST /timesheets/:id/review) требует write; при own — только для сотрудника своего дивизиона. Подробнее: Табели.
Дашборд и отчёты
dashboard.Service и reports.Service вырезают суммы по матрице, а не только скрывают их в UI:
| Поля ответа | Объект |
|---|---|
planned_revenue, actual_revenue, planned_budget | client_budget |
planned_costs, actual_costs | costs |
planned_profit, actual_profit | earnings_margin |
GET /forms/report («Отчёты → Помощник в работе») — только руководству компании: reports_dashboards.read = all, глобальный администратор или ai.manage; охват «свой» (руководитель отдела) получает 403 access_denied, отдельного сужения до своего отдела у этого отчёта нет. POST /finance/statements/match (выписка банка) — тем, кто ведёт платежи (accounting.manage / запись затрат), как и сами платежи.
GET /reports/expense_breakdown целиком состоит из объекта costs и без costs.read возвращает 403 reports_costs_forbidden. Без reports_dashboards.read = all отчёты и сводка /dashboard сужаются до всех отделов актора — основного и дополнительных, проект входит по основному отделу или project_departments, как на дашборде руководства (reports_department_scope_required, если отделов нет). Явный department_id фильтрует внутри этого охвата.
Касса и платежи (internal/modules/payments)
Охват считается по объекту costs с учётом legacy-кодов (payments.readScope / writeScope):
| Охват | Кто | Счета и кассы | Платежи | Остатки и отчёты |
|---|---|---|---|---|
| «все» | OWNER, OPS, FIN_DIR (запись); FIN_ASSIST (запись через accounting.manage, миграция 0148); accounting.view (чтение); accounting.manage, глобальный администратор | заводит | любые, в том числе без проекта; проводит | видит |
| «свой» | DIV_HEAD, MANAGER | только список (выбор в форме), без остатков и без opening_balance | только плановые платежи проектов своего дивизиона и проектов, которыми руководит | 403 payments_reports_forbidden |
Проекты платежа — проект статьи затрат (cost_item_id), позиции реестра обязательств (obligation_id) и документов разнесения (payment_allocations → documents.project_id); «свой» — department_id проекта, project_departments или manager_id проекта = пользователь.
- Провести платёж (статус
completedпри создании илиPATCH /payments/:id/status) может только финансовая служба: глобальный администратор, запись затрат «все» илиaccounting.manage. Остальным — 403payments_complete_forbidden; платёж без статуса у них создаётся плановым (planned), у финансов — проведённым, как раньше. - Создание и смена статуса при охвате «свой»: все проекты платежа должны быть своими, и хотя бы один проект обязателен. Не указаны ни статья затрат, ни обязательство, ни документ — 400
payments_project_required; проект чужой или платёж без проекта — 403payments_project_scope_forbidden(создание) илиpayments_forbidden(статус). - Реестр и карточка при охвате «свой»: платежи, хотя бы один проект которых — свой.
- Счета (
GET /accounts,/accounts/:id) открыты охвату «свой» для выбора в форме, ноopening_balanceв ответе есть только у охвата чтения «все». - Счёт или касса (
POST /accounts) — только охват записи «все», иначе 403payments_accounts_forbidden. - Выплата зарплаты по утверждённой ведомости создаёт платёж доменной логикой с полным охватом.
Интерфейс по capabilities (accounts_manage, payments_read_all) прячет «Добавить счёт» и остатки; форма платежа при охвате «свой» подсказывает выбрать проект своего дивизиона.
Глобальный администратор
Везде, где legacy-роль admin открывает всё (правка и удаление чужих сообщений в чатах, выдача доступа к разделам проекта, очередь заявок на оборудование, стол логиста, формирование документов, поиск, записи времени за других, профиль сотрудника), так же проходит владелец с system.global_admin. Общий помощник — transport.IsGlobalAdmin(c): роль admin (без учёта регистра) или код system.global_admin. Legacy-роль admin работает как прежде.
Реестр обязательств
Проверка в handler: позиции с recurrence_day — объект recurring_payments, остальные — obligations; GET требует read, POST/PUT/DELETE — write. Подробнее: Реестр обязательств.
Как матрица отдаётся фронту
GET /api/v1/org/me возвращает оргконтекст пользователя плюс поле permissions — разрешённую матрицу (transport.PermissionsFromContext):
{
"user": { "id": 12, "...": "..." },
"departments": [ { "id": 3, "name": "Видео", "...": "..." } ],
"primary_department": { "id": 3, "...": "..." },
"manager_chain": [],
"subordinates": [],
"permissions": {
"project_card": { "read": 2, "write": 1 },
"client_budget": { "read": 2, "write": 0 },
"costs": { "read": 1, "write": 1 },
"earnings_margin": { "read": 0, "write": 0 },
"others_timesheets": { "read": 1, "write": 1 },
"...": { "read": 0, "write": 0 }
}
}Значения read/write — числовой Scope: 0 = none, 1 = own, 2 = all. Ключи — коды объектов из таблицы выше, всегда все 16.
Итоговые возможности: capabilities
Доступ — объединение матрицы и legacy-кодов (решение этапа 1), а интерфейс знает только матрицу. Там, где сервер проверяет действие legacy-кодом, /org/me отдаёт итог в capabilities:
| Ключ | Что открывает | Как считается |
|---|---|---|
logistics_manage | очередь «Заявки от менеджеров» в логистике и действия с ней; операции без проекта | logistics.manage (как equipmentrequests.ListAll, logistics.checkWriteAccess) |
documents_manage | формирование документов проекта (счёт, смета, договор, акт) и смена их статуса | глобальный администратор или documents.manage (hasDocumentPrivileges) — все типы; сверх этого по типам: смета — запись client_budget по проекту (projects.Actor.MoneyAbilities), счёт, акт, счёт компании и смена статуса — финансы (transport.IsFinance или finance.manage), договор и допсоглашение — legal.manage. Проект дополнительно — раздел documents. Счёт компании без проекта скачивают те, кто ведёт такие счета или читает все документы |
nda_manage | публикация версии NDA (POST /nda/versions) | RequirePermissions("nda.manage"): код, роль admin или system.global_admin |
nda_report | журнал принятия NDA (GET /nda/report) | RequirePermissions("nda.report"), для чтения ещё system.global_read |
dashboard_view | дашборд руководства и отчёты (GET /dashboard/executive, /reports/*, /kpi/*) | чтение reports_dashboards или dashboard.view (как RequireObjectAccess). У ENGINEER/DIV_STAFF нет — их «Мой день» не запрашивает дашборд |
companies_manage | создание и правка компаний | запись counterparties_directory (у MANAGER и DIV_HEAD — «П») или companies.manage |
contacts_manage | создание и правка контактных лиц | запись counterparties_directory или contacts.manage |
accounts_manage | «Добавить счёт» (счета и кассы компании) | охват записи затрат «все»: costs.write = all, accounting.manage или глобальный администратор (payments.writeScope) |
payments_read_all | остатки по счетам, взаиморасчёты, ДДС, НДС, дебиторка | охват чтения затрат «все»: costs.read = all, accounting.view/accounting.manage, system.global_read или глобальный администратор (payments.readScope) |
payments_view | экран «Платежи» в меню | payments_read_all или запись затрат «свой» (плановые платежи своих проектов: DIV_HEAD, MANAGER); чтение затрат «свой» без записи экран не открывает |
chat_moderate | правка и удаление чужих сообщений в чатах | глобальный администратор (transport.IsGlobalAdmin) |
finance_approve | согласование сумм заявок на оборудование и перевозок (finance/approve, finance/reject) | transport.IsFinance: затраты «все» или глобальный администратор |
project_revenue_manage | фактическая выручка в форме проекта (при создании; карточка — access.revenue) | transport.IsFinance |
my_company_requisites_manage | правка реквизитов «Моей компании» | transport.IsFinance |
logistics_operate | заявки и перевозки любого проекта, перевозки без проекта | transport.RunsLogistics: logistics.manage, запись equipment_stock или глобальный администратор |
equipment_catalog_manage | новые позиции каталога, в том числе при импорте списка | transport.ManagesEquipmentCatalog: запись equipment_stock, equipment.manage или глобальный администратор |
automations_manage | настройка автодействий и журнал запусков (автодействия) | глобальный администратор или генеральный директор |
sales_dictionaries_manage | правка справочников продаж: причины проигрыша, источники заявок (продажи) | глобальный администратор, генеральный директор или руководитель отдела продаж |
sales_report | отчёт «Воронка продаж» | чтение reports_dashboards («все» или «свои»), dashboard.view, генеральный директор, администратор |
project_templates_manage | создание, правка и удаление шаблонов проектов, «Сохранить как шаблон» (шаблоны) | глобальный администратор, запись project_card «все» или руководитель отдела/дивизиона (departments.manager_id); добавляется через orgHTTP.WithCapabilities |
Новую возможность добавляют в internal/modules/org/transport_http.go (capabilities) теми же условиями, что в обработчике, и в тип AppCapability фронта.
Действия и матрица: что сверено
Интерфейс показывает действие, только если сервер его выполнит (аудит записывающих действий по 11 ролям):
- Смета из ТЗ → бюджет (
POST /projects/:id/specs/:v/estimate/apply) пишет плановые затраты, поэтому кроме ведения проекта требует записьcosts(projects_estimate_costs_forbidden). Технический директор (затраты «Р») смету видит, но в бюджет не переносит. - Команда проекта (
/project-team): добавить, изменить и исключить участника может только тот, кто ведёт этот проект, — то же правило, чтоaccess.manageкарточки (projects.Service.CanManageProject): запись карточки «все», запись «свой» для проекта своего дивизиона (department_idилиproject_departments), руководитель проекта (manager_id) с любой ролью, глобальный доступ (плюс руководитель отдела продаж и доменная политика Casbin, как у ведения проекта). Раньше проверки на уровне проекта не было: руководитель дивизиона и менеджер правили команду любого проекта. Отказ — 403projectteam_write_forbidden. Чтение команды не сужено. Синхронизация проектного чата идёт от имени того, кто прошёл эту проверку, — без личного участия в проекте. Интерфейс показывает «Добавить участника» и действия с участниками поaccess.manage. - Выдача доступа к разделам (
/project-team/:project_id/:user_id/permissions): руководитель проекта, руководитель отдела продаж и глобальный администратор — рольadminилиsystem.global_admin. - Контрагенты (
/companies,/contacts): охват считается отдельно для чтения и записи. Чтение «все» поcounterparties_directory(«П» или «Р», в том числе технический директор) иsystem.global_read— список и карточки не сужаются по ответственному менеджеру. Запись «все» (финансы, юристы, руководители дивизионов и менеджеры — клиентов ведут менеджеры) правит любую компанию; иначе только что созданная компания без ответственного отвечала бы 403. Роль, которая пишет только по legacy-кодуcompanies.manage(например, технический директор), правит свой клиентский контур (ответственный — он сам или подчинённые) и, создавая компанию без ответственного, сама становится ответственной (см. Security & RBAC). Отсутствующая компания или контакт — 404companies_company_not_found/contacts_contact_not_found, чужой контур — 403companies_access_denied/contacts_access_denied. - Вложенные ресурсы контрагентов (
/companies/:id/bank-accounts,/companies/:id/contacts,/contacts/:id/companies,/requisitesсowner_type=company|contact) проверяют доступ так же, как карточка владельца: чтение для GET, запись для изменений (CheckCompanyAccess/CheckContactAccess, подключены вinternal/app/counterparty_access_adapter.go). Привязка контакта к компании требует записи компании и чтения контакта. - Заявка на людей: руководитель отдела из позиций заявки (на согласовании или согласованной) открывает карточку проекта чужого дивизиона — чтобы по уведомлению назначить сотрудников (
projects.Service.Get,UserHeadsRequestedDepartment). - Фотоотчёты: участник проекта (менеджер, команда, исполнитель задачи) загружает файлы в папку «Фотоотчёты» (
files.Service.canUploadPhotoReport) без права править прочие файлы проекта; карточка отдаётaccess.photo_reports. Весь список файлов (GET /projects/:id/files) по-прежнему открыт по разделу документов. - Перевозки проекта (
GET /logistics?project_id=): кроме логистики проекта их читают его финансы — суммы операций согласует финансовый раздел (finance/approve), а логистика проекта FIN_DIR закрыта. Решение принимается в «Смета и бюджет» → «Перевозки ждут решения по сумме». - Командировки (
/trips): запись карточки проекта «все» (OWNER, OPS, TECH_DIR), финансы (transport.IsFinance: FIN_DIR согласует суммы) и глобальный доступ видят командировки всех отделов; раньше это давала только строка ролиadmin. Командировку по проекту можно создать только при доступе к проекту (trips_project_access_forbidden). Согласовать её (approved) и изменить фактическую стоимость может финансовая служба или назначенный согласующий (approver_id), иначе 403trips_approve_forbidden; при создании согласующего ещё нет — только финансы.PATCHпроходит middleware с одной загрузкой прав: править поездку могут организаторы (записьproject_cardилиtrips.manage), финансы и согласующий (trips_edit_forbidden). Свободный адрес без локации уходит вfrom_address/to_address: заводить локации может только склад. - Финансовая сводка проекта (
GET /finance/projects/:id/summary): прибыль (planned_profit,actual_profit) — только чтениеearnings_margin, выручка — чтениеclient_budget, как в карточке проекта и дашборде. - Состояние, а не права: закрытый проект в финансах (
finance_project_is_closed), правка и пересчёт не-черновика ведомости (payroll_only_draft_*) — 409; неподключённая выплата ведомости (payroll_payment_creator_is_not_configured) — 500. - Стол логиста (
GET /logisticsбезproject_id, операции без проекта): открыт тем, кто ведёт логистику (logistics.manage, записьequipment_stock«все» — склад) и глобальному доступу; остальные видят операции, где они водители. Создают операции склад и логистика (любые) и те, кто ведёт проект (только по своему проекту), см. «Каждый отвечает за своё».
Каждый отвечает за своё
Решение заказчика: финансы отвечают за деньги, менеджеры — за клиентов, инженеры и склад — за оборудование, логисты — за доставку. Общие проверки собраны в internal/transport/responsibility.go, чтобы обработчики разных модулей и capabilities считали одинаково:
| Помощник | Кто | Где |
|---|---|---|
transport.IsFinance | затраты costs «все» (OWNER, OPS, FIN_DIR) или глобальный администратор | согласование сумм заявок и перевозок, фактическая выручка, реквизиты «Моей компании» |
transport.RunsLogistics | logistics.manage, запись equipment_stock, глобальный администратор | заявки на оборудование и перевозки любого проекта |
transport.ManagesEquipmentCatalog | запись equipment_stock, equipment.manage, глобальный администратор | новые позиции каталога при импорте |
Контрагенты ведут менеджеры
counterparties_directory: у MANAGER и DIV_HEAD — «П» (было «Р»), у TECH_DIR — «Р». Сужение по ответственному менеджеру к ним больше не применяется; TECH_DIR читает весь справочник, а правит только свой контур. capabilities.companies_manage/contacts_manage пересчитываются по матрице.
Деньги проекта
| Поле | Кто задаёт | Проверка |
|---|---|---|
planned_budget (сумма сделки), close_probability | запись client_budget: «все» — финансы, владелец, операционный директор; «свой» (MANAGER, DIV_HEAD) — проект своего дивизиона (department_id или project_departments) или проект, где пользователь руководитель | Actor.canSetDealTerms, иначе 403 projects_deal_terms_forbidden |
actual_revenue | только финансы: затраты «все» или глобальный доступ | Actor.canSetRevenue, иначе 403 projects_revenue_forbidden |
- Проверяется и
POST /projects, иPUT /projects/:id. Поле, значение которого не меняется, не проверяется: форма карточки отправляет его вместе с остальными полями. - Финансы без записи карточки (
FIN_DIR,FIN_ASSIST:project_card«Р»):PUT /projects/:id, в теле которого только денежные поля, проходит по праву на них без права правки карточки. Любое другое поле в том же запросе — снова по правилу правки (projects_department_forbidden). - Карточка отдаёт
access.moneyиaccess.revenue; интерфейс по ним блокирует поля в форме, а финансам без правки карточки даёт отдельное действие «Изменить деньги проекта». - Вероятность закрытия видят те, кто её задаёт (запись
client_budget); взвешенный заработок — по-прежнемуprobability_weighted.
Выдача прав
internal/modules/rbac/grant.go, выдающий — transport.GrantorFromContext (пользователь, глобальный администратор, legacy-коды, роли и матрица по всем ролям). Раздавать права вообще может тот, кого пускает middleware маршрута: запись users_and_rights или legacy users.manage (/users, /rbac), org.manage (/org).
- Владельческое — роль
OWNER(и legacyadmin/owner) и кодsystem.global_admin— выдаёт и снимает только глобальный администратор, кому угодно — 403rbac_grant_owner_only. Права роли владельца и полный доступ в любой роли меняет тоже только он. - Другим пользователям кадровые права раздают любые роли и коды, кроме владельческих, даже если они шире собственных: операционный директор делает сотрудника
FIN_DIR,FIN_ASSISTилиLEGAL. - Себе — ничего сверх своего (защита от самоэскалации): роль, коды которой или столбец матрицы шире собственных прав, и код, которого у самого нет, — 403
rbac_grant_self_escalation. Снятие с себя запрета (deny) возвращает код и проверяется так же. Роль, которую выдающий носит сам, не расширяется сверх его прав (PUT /rbac/roles/:role/permissions); новую роль можно создать с любыми кодами, кроме полного доступа, — выдача её себе проверяется при назначении. - Снятие прав: у себя — можно; у других — по тем же правилам, что выдача (владельческое — только глобальный администратор).
- Места проверки:
/users/:id/roles,/users/:id/permissions,/rbac/roles*(rbac),POST/PUT /users(роль сотрудника; новый сотрудник — «другой»),/org/positions(роли должности: только владельческие под запретом) иPUT /org/users/:id/position(роли должности выдаются назначенному — себе ничего сверх своего). PUT /users/:idтрогаетuser_rolesтолько при смене поляrole, и тогда заменяет лишь основную роль: прежняя основная проверяется как снятая, новая — как выданная, остальные роли (выданные через/users/:id/rolesили должностью) остаются. Правка без смены роли (телефон, имя, отделы) проверку выдачи не вызывает.- Учётная запись владельца (роль владельца в
users.roleилиuser_roles): имя, отделы, активность и роль меняет только глобальный администратор — иначе 403users_owner_account_global_admin_only.DELETE /rbac/roles/:roleиPUT/PATCH /rbac/roles/:roleдля роли владельца (и роли сsystem.global_admin) — тоже только он (rbac_grant_owner_only); рольadminне удаляется никем. - Последний владелец: снять роль владельца (
PUT /users/:id,DELETE /users/:id/roles/:role,PUT /users/:id/roles) или отключить учётную запись последнего активного владельца нельзя — 409users_cannot_remove_admin_role_from_the_last_administrator/users_cannot_deactivate_the_last_owner/rbac_cannot_remove_admin_role_from_the_last_administrator. Владельцы считаются поusers.roleиuser_rolesвместе (users.LosesLastOwner). - Основная роль (
users.role) выбирается по приоритету (permissions.PrimaryRole): роль владельца первой, затем по порядку колонок матрицы; добавление роли (POST /users/:id/roles/:role) основную не понижает,PUT /users/:id/rolesне зависит от порядка в запросе. Должность (PUT /org/users/:id/position) не меняет основную роль владельца. GET /rbac/rolesиGET /rbac/permissionsразмечают каждый элемент полямиgrantableиgrant_hint— можно ли выдать его другому пользователю. Выдачу себе сервер проверяет при сохранении; админка в карточке самого пользователя показывает это подсказкой.
Реквизиты «Моей компании»
/settings/my-company/requisites и /my-company/requisites читают все сотрудники (раньше второй маршрут требовал projects.manage), правят только финансы (transport.IsFinance — то же правило, что у capability my_company_requisites_manage), иначе 403 requisites_my_company_finance_only. То же для owner_type=internal через общий /requisites. Реквизиты клиентов и контактов — по доступу к карточке владельца (см. «Вложенные ресурсы контрагентов»).
Оборудование и доставка
- Заявки на оборудование проекта (создать, править позиции и потребности, отправить, отменить, архивировать, импортировать): участники команды проекта и те, кто его ведёт (
CanManageProject), склад и логистика (RunsLogistics). Руководитель дивизиона и менеджер в чужом проекте — 403equipment_requests_write_forbidden. Правило —internal/app/project_supply_access.go. - Отклонить заявку — те, кто ведёт проект, склад и логистика; архивировать — они же на любом этапе, участник команды — только черновик. Закупка, план операций, автоплан, очередь и заявки без проекта — только склад и логистика (
RunsLogistics); очередь и заявки проекта читают и финансы (IsFinance). - Каталог оборудования (
POST/PUT/DELETE /equipment, новые позиции при импорте списка) ведут склад и инженеры —ENGINEER,TECH_DIR(«инженеры отвечают за оборудование»; по матрице склад им открыт на чтение) — плюсequipment.manageи глобальный администратор (ManagesEquipmentCatalog, capabilityequipment_catalog_manage), иначе 403equipment_catalog_write_forbidden. Локации и поставщики — по-прежнему склад. - Перевозка (
POST /logistics): склад и логистика — любые; руководитель проекта и те, кто его ведёт, — только по своему проекту,project_idобязателен (400 без него). Вести операцию (правка, удаление, «взять», подтвердить, закрыть) — тот же круг, остальным 403logistics_write_forbidden. Финансы читают операции и очередь. - История сделок поставщика (
GET /suppliers/:id/deals): суммы перевозок — это затраты, без чтенияcostsони вырезаются из ответа. - Связи записей (
/entity-links,/entities/:type/:id/related): связать или отвязать записи может тот, кто видит обе и вправе править хотя бы одну (компании, контакты, реквизиты —counterparties_directory; договоры —contracts; проект, документ, счёт, акт, файл — доступ к проекту), иначе 403entitylinks_link_forbidden; навигационный хаб отдаёт только видимые записи. - Согласование суммы заявок и перевозок (
finance/approve|reject) — только финансы (IsFinance). Менеджер с затратами «свой» свою перевозку не согласует. - Фотоотчёты — без изменений: участники проекта загружают только в папку «Фотоотчёты».
Отдельные эндпоинты по ролям
GET /dashboard/warehouse— главная склада (перевозки, оборудование в пути, склады) по чтениюequipment_stock. Складу отчёты (reports_dashboards) по матрице не положены, поэтому дашборд руководства ему закрыт.- Чаты доступны всем сотрудникам: доступ к конкретному чату проверяет
chats.Service(участник чата), legacy-кодыchat.*на входе больше не нужны.
Проверка ролей в браузере
Сид (go run ./cmd/seed) создаёт пользователя на каждую роль матрицы (ops@, findir@, techdir@, divhead@, warehouse@seed.rms и legacy-роли). Во фронтенде pnpm e2e (Playwright, e2e/permissions.spec.ts) под каждым обходит все экраны и вкладки карточки проекта и падает на GET-ответе 403, тосте, ошибке JS, ответе 5xx или пункте меню, ведущем на «Нет доступа».
Что важно для фронта
- Интерфейс скрывает поля и кнопки по тем же данным, по которым сервер их вырезает:
canRead(object)=read > 0,canWrite(object)=write > 0,canReadAll(object)=read === 2. Один источник —permissionsиз/org/me, сбрасывать при logout. - Не выводите отсутствие поля как «0». Если
planned_budgetилиeffective_close_probabilityнет в ответе — у роли нет доступа, поле должно исчезнуть из карточки, списка и экспорта. - Списки проектов и задач для
O-ролей уже сужены сервером; фронт не должен дополнительно фильтровать поdepartment_id— иначе исчезнут проекты, где сотрудник участвует лично, но которые идут по другому дивизиону. - Кнопки «Утвердить табель», «Создать обязательство», «Опубликовать версию NDA» показываются по
writeсоответствующего объекта; для NDA — по capabilitynda_manage(сервер проверяет legacy-кодnda.manage, которого в матрице нет, см. NDA). - Справочники отделов и локаций можно запрашивать под любой ролью (см. выше), но форму создания/редактирования показывать только при
write. - Роли могут сочетаться; не выводите доступ из «главной» роли пользователя — только из
permissions.
Проверка
- Тесты матрицы:
go test ./internal/permissions/...(matrix_test.go), middleware:go test ./internal/modules/rbac/..., разделы проекта:go test ./internal/authorization/.... - Кто какие permission-коды получает миграцией: docs/rbac_matrix.md → «Роли спецификации RMS».