Skip to content

Матрица прав 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 (вырезание полей)

Символы ​

СимволВ xlsxAccessСмысл
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.

ОбъектOWNEROPSFIN_DIRFIN_ASSISTLEGALTECH_DIRDIV_HEADMANAGERENGINEERDIV_STAFFWAREHOUSE
project_card — карточка проектаFFRRRFOORR-
project_status_dates — статус и датыFFRRRFOOR--
client_budget — бюджет клиента (сумма сделки, вероятность закрытия)FFFFRROO---
costs — затраты / себестоимостьFFFR-ROO---
earnings_margin — заработок и маржаFFF--------
probability_weighted — вероятность и взвешенный заработокFFR--------
contracts — договоры и документыFRFFFR-R---
obligations — реестр обязательствFRFFR------
recurring_payments — постоянные платежиFRFF-------
departments_directory — справочник дивизионовFF---------
counterparties_directory — контрагентыFFFFFRFF---
equipment_stock — оборудование и складFF---RRRRRF
users_and_rights — пользователи и праваFF---------
reports_dashboards — отчёты и дашбордFFFR-OOO---
own_timesheet — свой табельFFFFFFFFFFF
others_timesheets — чужие табелиFFRR-FO----

Два объекта заведены в матрице, но отдельной проверки в коде пока не имеют: own_timesheet (у всех F, свой табель правами не гейтится) и project_status_dates (статус и даты сейчас идут вместе с project_card).


Как считаются права пользователя ​

  1. Роли складываются. permissions.Resolve(roles) берёт по каждому объекту максимум отдельно по чтению и по записи. Сотрудник с ролями MANAGER + FIN_ASSIST получит project_card: read=all (от FIN_ASSIST), write=own (от MANAGER).

  2. Глобальный админ получает всё. permissions.FromRoles(roles, globalAdmin) возвращает Full() (все объекты F), если у пользователя есть permission-код system.global_admin. Матрица введена поверх старой модели и не должна сужать уже выданные права. OWNER получает этот код миграцией 0137, admin имеет его с 0061.

  3. Раздел открывает галочка в роли. 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 не открывается — этот код открывает только «Договоры».

  4. Неизвестные роли прав не дают. NormalizeRole возвращает пустую строку — такая роль пропускается.

  5. Список ролей берётся из контекста запроса (user_roles, заполняет auth middleware); если списка нет — из основной роли токена (user_role).

Legacy-алиасы ролей ​

Роли, заведённые до спецификации, отображаются на ближайшую роль матрицы (legacyRoleAliases), чтобы существующие пользователи не потеряли доступ при выкате:

Legacy-рольРоль матрицы
admin, ownerOWNER
directorOPS
head_of_departmentDIV_HEAD
manager, project_manager, pmMANAGER
engineerENGINEER
employee, userDIV_STAFF
accountantFIN_ASSIST
legal, lawyerLEGAL
warehouse, logist, logisticianWAREHOUSE

Сравнение регистронезависимое: 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 forbidden

Fallback-коды — коды старой модели, они сохраняют доступ ролям, заведённым до матрицы (например, 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-коды, которые сохраняют доступ.

МаршрутыОбъектMiddlewareFallback
/users (создание, админ-карточка, изменение), /rbac/*users_and_rightsRequireObjectAccessusers.manage
/settings/my-company/requisites, /my-company/requisites— (читают все; запись — финансы, см. «Каждый отвечает за своё»)AttachPermissions + проверка в обработчике—
/org/* (изменение иерархии, должности)users_and_rightsRequireObjectAccessorg.manage
/departmentsdepartments_directoryRequireObjectWrite (чтение всем)departments.manage
/companies, /companies/:id/bank-accounts, /companies/:id/contacts, /requisitescounterparties_directoryRequireObjectAccesscompanies.manage
/contacts, /contacts/:id/companiescounterparties_directoryRequireObjectAccesscontacts.manage
/locationsequipment_stockRequireObjectWrite (чтение всем)locations.manage
/equipmentequipment_stockRequireObjectAccessequipment.manage
/suppliersequipment_stockRequireObjectAccesssuppliers.manage
/finance/*costsRequireObjectAccessfinance.manage
/projects/:id/budget/*client_budget или costs (чтение — чтение любого, запись настроек — запись любого)budget.RequireBudgetAccess; проект — раздел financefinance.manage
/accounts, /payments, /counterparties/*, платёжные /reports/*costs (охват «свой» сужает сервис, см. «Касса и платежи»)RequireObjectAccessaccounting.view (GET), accounting.manage
/tripsproject_card (PATCH — RequireObjectRead: решает обработчик, см. «Командировки»)RequireObjectAccesstrips.manage
/project-teamproject_cardRequireObjectRead: чтение — по объекту, запись решает обработчик по проекту (см. «Команда проекта»)project_team.manage
/dashboard, /kpi, /reports/*reports_dashboardsRequireObjectAccessdashboard.view
/document-templatescontracts (чтение — любой читающий договоры, запись — только documents.manage)RequireObjectAccessdocuments.manage
/contracts/*contractsRequireObjectAccesslegal.view (GET), legal.manage
/compensation-profiles, /payroll/*earnings_marginRequireObjectAccesspayroll.view (GET), payroll.manage
/obligationsobligations / recurring_paymentsпроверка в handler (requireRead/requireWrite)—
/timesheets (сводный, чужой, review)others_timesheetsпроверка в handler—
/time-entries (GET)others_timesheets (сужение выборки в обработчике)только аутентификация—
/absences, /calendarothers_timesheets (чтение — охват чужих отсутствий), users_and_rights (запись — заводит за любого, календарь и настройки)AttachPermissions; руководитель и генеральный директор — по оргструктуре и настройкам в сервисе—
/time-entries (POST)—RequirePermissionsOrDepartmentManagertime_entries.manage
/projects, /tasks, /team-requests, /projects/:id/documents/*, /equipment-requests, /logistics, /search, /audit, /entities/*/timelineproject_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 → ок (иначе Casbin projects/read по домену отдела); иначе требуется личное участие (менеджер, команда, задачи).
  • Create: canWriteAll — любой дивизион; canWriteOwn — только в своём (department_id из запроса должен совпадать); иначе Casbin projects/write.
  • canManageProject (update, статус, удаление, спецификации): canWriteAll → ок; canWriteOwn && inDivisions → ок; руководитель отдела продаж → ок; Casbin projects/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 сначала смотрит объект матрицы, который открывает раздел целиком, и только потом проверяет участие в команде и проектные роли:

РазделОбъект матрицыКто проходит без участия в проекте
financecostscosts.read = all (OWNER, OPS, FIN_DIR, FIN_ASSIST; у остальных — только при включённом разделе «Финансы», см. выше)
documentscontractscontracts.read = all
logisticsequipment_stockequipment_stock.read = all

GET /api/v1/projects/:id отдаёт результат этих проверок для текущего пользователя в поле access:

json
"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 = дивизионы актора
none403 timesheets_foreign_forbiddenвыборка сужается до user_id = я

Утверждение (POST /timesheets/:id/review) требует write; при own — только для сотрудника своего дивизиона. Подробнее: Табели.

Дашборд и отчёты ​

dashboard.Service и reports.Service вырезают суммы по матрице, а не только скрывают их в UI:

Поля ответаОбъект
planned_revenue, actual_revenue, planned_budgetclient_budget
planned_costs, actual_costscosts
planned_profit, actual_profitearnings_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. Остальным — 403 payments_complete_forbidden; платёж без статуса у них создаётся плановым (planned), у финансов — проведённым, как раньше.
  • Создание и смена статуса при охвате «свой»: все проекты платежа должны быть своими, и хотя бы один проект обязателен. Не указаны ни статья затрат, ни обязательство, ни документ — 400 payments_project_required; проект чужой или платёж без проекта — 403 payments_project_scope_forbidden (создание) или payments_forbidden (статус).
  • Реестр и карточка при охвате «свой»: платежи, хотя бы один проект которых — свой.
  • Счета (GET /accounts, /accounts/:id) открыты охвату «свой» для выбора в форме, но opening_balance в ответе есть только у охвата чтения «все».
  • Счёт или касса (POST /accounts) — только охват записи «все», иначе 403 payments_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):

json
{
  "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, как у ведения проекта). Раньше проверки на уровне проекта не было: руководитель дивизиона и менеджер правили команду любого проекта. Отказ — 403 projectteam_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). Отсутствующая компания или контакт — 404 companies_company_not_found / contacts_contact_not_found, чужой контур — 403 companies_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), иначе 403 trips_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.RunsLogisticslogistics.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 (и legacy admin/owner) и код system.global_admin — выдаёт и снимает только глобальный администратор, кому угодно — 403 rbac_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): имя, отделы, активность и роль меняет только глобальный администратор — иначе 403 users_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) или отключить учётную запись последнего активного владельца нельзя — 409 users_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). Руководитель дивизиона и менеджер в чужом проекте — 403 equipment_requests_write_forbidden. Правило — internal/app/project_supply_access.go.
  • Отклонить заявку — те, кто ведёт проект, склад и логистика; архивировать — они же на любом этапе, участник команды — только черновик. Закупка, план операций, автоплан, очередь и заявки без проекта — только склад и логистика (RunsLogistics); очередь и заявки проекта читают и финансы (IsFinance).
  • Каталог оборудования (POST/PUT/DELETE /equipment, новые позиции при импорте списка) ведут склад и инженеры — ENGINEER, TECH_DIR («инженеры отвечают за оборудование»; по матрице склад им открыт на чтение) — плюс equipment.manage и глобальный администратор (ManagesEquipmentCatalog, capability equipment_catalog_manage), иначе 403 equipment_catalog_write_forbidden. Локации и поставщики — по-прежнему склад.
  • Перевозка (POST /logistics): склад и логистика — любые; руководитель проекта и те, кто его ведёт, — только по своему проекту, project_id обязателен (400 без него). Вести операцию (правка, удаление, «взять», подтвердить, закрыть) — тот же круг, остальным 403 logistics_write_forbidden. Финансы читают операции и очередь.
  • История сделок поставщика (GET /suppliers/:id/deals): суммы перевозок — это затраты, без чтения costs они вырезаются из ответа.
  • Связи записей (/entity-links, /entities/:type/:id/related): связать или отвязать записи может тот, кто видит обе и вправе править хотя бы одну (компании, контакты, реквизиты — counterparties_directory; договоры — contracts; проект, документ, счёт, акт, файл — доступ к проекту), иначе 403 entitylinks_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 — по capability nda_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».

Загружаем документацию…