PUT /api/v1/roles/{roleId}/permissions — Настройка permissions роли
Полная замена состава прав роли компании (RolePermission): клиент присылает целевой набор
PermissionIds, система вычисляет дельту grant/revoke и рассылает UserAccessChangedKafkaEvent всем
пользователям с активными назначениями этой роли.
Назначение
Руководитель настраивает, что может делать роль («может публиковать новости, но не может редактировать
проект»). Owning-контекст — identity. Feature group — roles.
UI ролей работает чекбоксами по каталогу прав. PUT-семантика (declarative set) — клиент присылает весь
целевой набор PermissionIds, сервер сам вычисляет дельту — атомарна и идемпотентна, поэтому выбрана
вместо пары grant/revoke-эндпоинтов.
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | identity |
| API area | public |
| Feature group | roles |
| Статус | draft |
| Документ | docs/06-services/identity/api/company-user/set-role-permissions.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (пользователь компании) |
| Auth policy / Permissions | identity.roles.manage |
| Scope (RBAC) | организация: Active RoleAssignment со scope Platform или Organization(CompanyId) |
| Record-level | роль обязана принадлежать компании актора |
| Tenant isolation | TenantId = CompanyId |
| Audit | update: RolePermission.CreatedByUserId на добавленных, RoleUpdatedEvent с дельтой, UserAccessChangedKafkaEvent fan-out |
| MFA/approval | не требуется; эскалация прав невозможна by-design: в набор допускаются только IsAssignableByCompanies = true (INV-P4) — платформенные права компания включить не может |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | PUT |
| Route | /api/v1/roles/{roleId}/permissions |
| Success status | 200 |
| Idempotency header | не применяется (PUT declarative set идемпотентен) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
ActorUserId | identity claims | Guid | Да | Required | — |
CompanyId | identity claims (tenant_id) | Guid | Да | Required | Tenant isolation |
RoleId | route {roleId} | Guid | Да | != Guid.Empty | — |
PermissionIds | body | IReadOnlyList<Guid> | Да (пустой = снять все права) | <= 113, без дубликатов; каждый — существующий, IsAssignableByCompanies = true, IsDeprecated = false (INV-R7, INV-P4) | — |
Version | body | long | Да | >= 1; сверка с RoleDefinition.Version | — |
Модель ответа SetRolePermissionsCommandResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
RoleId | Guid | Да | RoleDefinition.Id | — | Id роли. |
GrantedCodes | IReadOnlyList<string> | Да | дельта: добавленные PermissionDefinition.Code | — | Что добавлено. |
RevokedCodes | IReadOnlyList<string> | Да | дельта: удалённые коды | — | Что снято. |
PermissionCodes | IReadOnlyList<string> | Да | итоговый состав | — | Полный целевой набор. |
AffectedUsersCount | int | Да | число пользователей с Active-назначениями роли | — | Скольким пересчитаны права. |
Version | long | Да | RoleDefinition.Version | — | Новая версия роли. |
Предусловия и постусловия
Предусловия. Актор аутентифицирован и имеет identity.roles.manage в scope Organization(CompanyId);
роль существует, принадлежит компании актора, не системная (INV-R1) и Active; переданная Version
совпадает с текущей версией роли; все PermissionIds — существующие, назначимые компаниями и не устаревшие.
Постусловия при успехе. Состав прав роли равен целевому PermissionIds; версия роли увеличена,
зафиксирован автор; на каждого пользователя с активным назначением этой роли пересчитаны эффективные права
и опубликовано UserAccessChangedKafkaEvent (при > 100 затронутых — через background-джобу батчами по 100);
кэши perm:{userId} затронутых инвалидированы (best-effort). При пустой дельте изменений и событий нет.
Постусловия при отказе. Состав прав и версия роли не изменились, события не публикуются.
Алгоритм
- Контекст и доступ. Операцию выполняет пользователь компании; tenant — его компания (
CompanyId); требуется правоidentity.roles.manageв scopeOrganization(CompanyId). Эскалация прав невозможна by-design: в набор допускаются только права сIsAssignableByCompanies = true(INV-P4). - Проверка входа и предусловий.
- Синтаксис: лимит и отсутствие дубликатов в
PermissionIds; иначе —ValidationError. - Роль существует и принадлежит компании актора; иначе —
IDENTITY_ROLE_NOT_FOUND. - Роль не системная (INV-R1: состав прав системных ролей меняет только платформа); системная —
IDENTITY_ROLE_SYSTEM_IMMUTABLE. - Роль в статусе
Active;Archived→IDENTITY_ROLE_ARCHIVED. - Все переданные права существуют в SEED-каталоге (INV-R7); отсутствующие →
IDENTITY_PERMISSION_NOT_FOUND. - Каждое право назначимо компаниями и не устарело (INV-P4):
IsAssignableByCompanies = true,IsDeprecated = false; иначе —IDENTITY_PERMISSION_NOT_ASSIGNABLE(в ответе — коды-нарушители). - Оптимистичная блокировка: переданная
Versionсовпадает с версией роли; иначе —IDENTITY_CONCURRENCY_CONFLICT.
- Синтаксис: лимит и отсутствие дубликатов в
- Что читаем. Роль с текущим составом прав; каталог прав (in-memory SEED). При непустой дельте — идентификаторы пользователей с активными назначениями этой роли (для fan-out).
- Бизнес-правила и переходы. Вычисляется дельта относительно текущего состава:
toGrant= целевые \ текущие,toRevoke= текущие \ целевые. Пустая дельта → короткий путь: результат с пустыми списками, без изменений и событий. Иначе: добавляются привязки правtoGrant(с автором), удаляются привязкиtoRevoke; версия роли увеличивается, фиксируется автор изменения. Поднимается доменный фактRoleUpdatedEventс дельтой. - Что меняется. Обновляются роль и её привязки прав (добавление/удаление). Конкурентный конфликт →
IDENTITY_CONCURRENCY_CONFLICT. - Какие факты/события фиксируются. На каждого пользователя с активным назначением роли публикуется
UserAccessChangedKafkaEvent(топикpin.identity.access.v1, ключUserId) с пересобранным списком активных назначений{RoleCode, ScopeType, ScopeIds}и версией. При более чем 100 затронутых пользователях fan-out делегируется background-джобе батчами по 100: вместо N событий фиксируется задача (role.md«События»). - Согласованность. Роль, её привязки прав и события (или маркер джобы) фиксируются в одном согласованном изменении.
- Результат. Идентификатор роли, дельта (добавленные/снятые коды), итоговый состав прав, число
затронутых пользователей и новая версия (
200). После фиксации — best-effort инвалидация кэшаperm:{userId}затронутых.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Интеграционные события | UserAccessChangedKafkaEvent ×N → pin.identity.access.v1 (сброс кэшей авторизации у crm/catalog/chessboard/gateway) |
| Проекции / кэш | кэш perm:{userId} затронутых — инвалидация после фиксации |
Интеграционная последовательность и согласованность
Изменение состава прав роли затрагивает всех её носителей — это fan-out:
- Вычисляется дельта
toGrant/toRevoke. Пустая дельта → короткий путь: результат с пустыми списками, без изменений и без событий (идемпотентность повторного PUT того же набора). - При непустой дельте состав прав и версия роли фиксируются; определяется множество пользователей с активными назначениями роли.
- На каждого затронутого публикуется
UserAccessChangedKafkaEvent→ топикpin.identity.access.v1, ключUserId(упорядоченность на пользователя — по ключу). При числе затронутых ≤ 100 события уходят в том же согласованном изменении; при > 100 фиксируется задача, и фактический fan-out выполняет background-джоба батчами по 100 (защита от «толстой» транзакции). - Потребители авторизации (
crm,catalog,chessboard, gateway) по каждому событию сбрасывают кэш доступа соответствующего пользователя.
Окно eventual-consistency тем шире, чем больше носителей роли (крупные роли — через джобу); на время
рассылки разные пользователи могут увидеть новые права роли неодновременно. Отказоустойчивость: событие
фиксируется атомарно с изменением состава прав (не теряется при сбое отправки), недоставленные —
ретраятся; исчерпавшие ретраи уходят в DLQ и переигрываются runbook-ом «access fan-out replay»
(наблюдаемость — метрика identity_access_fanout_size, алерт на лаг/DLQ). Повтор всей операции безопасен:
PUT — declarative set, тот же набор даёт пустую дельту.
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
ValidationError | 400 | Лимит/дубликаты PermissionIds | Список ошибок | no retry |
IDENTITY_ROLE_NOT_FOUND | 404 | Роль не существует / чужая | «Роль не найдена» | no retry |
IDENTITY_ROLE_SYSTEM_IMMUTABLE | 409 | IsSystem = true (INV-R1) | «Состав прав системной роли меняется только платформой» | no retry |
IDENTITY_ROLE_ARCHIVED | 409 | Status = Archived | «Роль архивирована» | no retry |
IDENTITY_PERMISSION_NOT_FOUND | 400 | Id вне SEED-каталога (INV-R7) | «Право не найдено» | no retry |
IDENTITY_PERMISSION_NOT_ASSIGNABLE | 409 | Платформенное/deprecated право (INV-P4) | «Право недоступно для ролей компании» + коды | no retry |
IDENTITY_CONCURRENCY_CONFLICT | 409 | Версия роли изменилась параллельно | «Данные изменены параллельно» | retry после перечитывания |
FORBIDDEN | 403 | Нет identity.roles.manage | «Недостаточно прав» | no retry |
Совместимость и наблюдаемость
Контракт PUT-набора стабилен; появление deprecated-права в существующем составе не ломает роль
(permission.md «Совместимость»: работает до миграции), но повторный PUT обязан его исключить.
- Logs:
RoleId, размер дельты,AffectedUsersCount; metrics:identity_role_permissions_set_total{result},identity_access_fanout_size; traces: OTel; dashboardidentity-overview; alert: DLQ/лаг fan-out; runbook: access fan-out replay.
Acceptance Criteria
- Given роль компании
Activeс набором прав{A, B}и актуальнаяVersion, WhenPUTсPermissionIds = {B, C}, Then200;GrantedCodes = {C},RevokedCodes = {A},PermissionCodes = {B, C}, версия увеличена; на носителей роли опубликованUserAccessChangedKafkaEvent. - Given целевой набор равен текущему, When
PUT, Then200с пустымиGrantedCodes/RevokedCodes; изменений и событий нет (идемпотентно). - Given
PermissionIds = [], WhenPUT, Then все текущие права сняты,RevokedCodes= прежний состав,PermissionCodes = []. - Given роль
IsSystem = true, WhenPUT, ThenIDENTITY_ROLE_SYSTEM_IMMUTABLE(409) — INV-R1. - Given роль другой компании, When
PUT, ThenIDENTITY_ROLE_NOT_FOUND(404). - Given переданная
Versionне совпадает с текущей, WhenPUT, ThenIDENTITY_CONCURRENCY_CONFLICT(409); состав прав не изменён. - Given в наборе право с
IsAssignableByCompanies = falseилиIsDeprecated = true, WhenPUT, ThenIDENTITY_PERMISSION_NOT_ASSIGNABLE(409) с кодами-нарушителями. - Given в наборе id вне SEED-каталога, When
PUT, ThenIDENTITY_PERMISSION_NOT_FOUND(400) — INV-R7. - Given роль назначена > 100 пользователям и дельта непуста, When
PUT, Then200немедленно,AffectedUsersCountотражает число носителей, рассылкаUserAccessChangedKafkaEventделегируется background-джобе батчами по 100. - Given роль
Archived, WhenPUT, ThenIDENTITY_ROLE_ARCHIVED(409).
Обратные ссылки
Автоссылки: где используется этот документ.
- API (5): GET /api/v1/permissions — Каталог permissions (SEED), GET /api/v1/roles — Список ролей компании, Identity Company-User API — реестр методов и общие правила области, POST /api/v1/roles — Создание роли компании, PUT /api/v1/roles/{roleId} — Обновление роли компании
- Use Cases (1): UC-IDN-005. Создание роли компании и назначение агенту в scope проекта
- Фоновые задачи (1): Background Job: AccessFanOutJob — веерная рассылка UserAccessChangedKafkaEvent при смене состава прав роли
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущности: domain/role.md (INV-R1/R7,
GrantPermission/RevokePermission), domain/permission.md (INV-P4, каталог). - Каталог для UI: list-permission-catalog.md.