POST /api/v1/role-assignments — Назначение роли пользователю в scope
Создание RoleAssignment: роль выдаётся пользователю в scope Organization / ProjectGroup /
Project (Scoped-RBAC: «редактирует все проекты» = scope организации, «редактирует только проект X» =
scope проекта). Платформенные scope выдаёт только администратор платформы (INV-R3, ADR-0053).
Назначение
Руководитель выдаёт сотруднику роль: агенту — роль агентства, менеджеру застройщика — роль на всю
организацию, группу проектов или конкретный проект. Owning-контекст — identity (дом полей —
domain/role.md). Feature group — role-assignments.
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | identity |
| API area | public |
| Feature group | role-assignments |
| Статус | draft |
| Документ | docs/06-services/identity/api/company-user/grant-role-assignment.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (пользователь компании) |
| Auth policy / Permissions | identity.role-assignments.manage (дефолт: agency-owner, developer-manager) |
| Scope (RBAC) | INV-R8 GranterAuthority: permission актора обязан быть выдан в scope, покрывающем выдаваемый scope по иерархии Platform ⊃ Organization ⊃ ProjectGroup ⊃ Project (алгоритм покрытия — check-permission.md) |
| Record-level | целевой пользователь — сотрудник компании актора |
| Tenant isolation | TenantId = CompanyId; Scope.OrganizationId обязан равняться CompanyId (кросс-tenant выдача невозможна) |
| Audit | create: GrantedByUserId = actor, GrantedAt; полная история назначений (записи не удаляются — role.md) |
| MFA/approval | не требуется; эскалация ограничена INV-R2/R3/R8 |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /api/v1/role-assignments |
| Success status | 201 |
| Idempotency header | Idempotency-Key (Guid); дополнительная естественная идемпотентность — INV-R6 (дубль Active-назначения невозможен) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
ActorUserId | identity claims | Guid | Да | Required | — |
CompanyId | identity claims (tenant_id) | Guid | Да | Required | Tenant isolation |
IdempotencyKey | header Idempotency-Key | Guid | Да | Required | — |
UserId | body | Guid | Да | существующий пользователь компании актора | — |
RoleId | body | Guid | Да | существующая Active-роль: этой компании (INV-R2) или системная организационная (agent/agency-owner/developer-manager) | — |
ScopeType | body | string (enum ResourceScopeType) | Да | Organization / ProjectGroup / Project; Platform запрещён (INV-R3 — только администратор платформы) | — |
ProjectGroupId | body | Guid? | Обязателен при ScopeType = ProjectGroup, иначе запрещён (INV-R4) | id группы проектов из chessboard; существование — API chessboard (шаг 2) | — |
ProjectId | body | Guid? | Обязателен при ScopeType = Project, иначе запрещён (INV-R4) | id проекта из chessboard; существование + принадлежность компании — API chessboard | — |
ExpiresAt | body | DateTimeOffset? | Нет | > now; временный доступ (job переведёт в Expired) | — |
OrganizationId в bodyOrganizationId в body отсутствует: всегда берётся из claims (CompanyId) — предотвращает
кросс-tenant подмену.
Модель ответа GrantRoleAssignmentCommandResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
AssignmentId | Guid | Да | RoleAssignment.Id | — | Id назначения (для отзыва). |
UserId | Guid | Да | RoleAssignment.UserId | — | Кому выдано. |
RoleCode | string | Да | RoleDefinition.Code | — | Код роли. |
ScopeType | string | Да | ResourceScope.Type | — | Итоговый scope. |
ScopeOrganizationId | Guid | Да | ResourceScope.OrganizationId | — | Организация scope. |
ScopeProjectGroupId | Guid? | Нет | ResourceScope.ProjectGroupId | — | Группа. |
ScopeProjectId | Guid? | Нет | ResourceScope.ProjectId | — | Проект. |
ExpiresAt | DateTimeOffset? | Нет | RoleAssignment.ExpiresAt | — | Срок. |
GrantedAt | DateTimeOffset | Да | RoleAssignment.GrantedAt | — | Момент выдачи. |
Предусловия и постусловия
Предусловия. Актор аутентифицирован и имеет identity.role-assignments.manage в scope, покрывающем
выдаваемый (INV-R8); целевой пользователь — сотрудник компании актора в статусе Invited/Active; роль
существует, Active и совместима с компанией (INV-R2); при scope ProjectGroup/Project — ресурс
существует в chessboard и принадлежит компании.
Постусловия при успехе. Появилось активное назначение Status = Active на тройку (пользователь, роль,
scope), Version = 1, зафиксированы автор и момент выдачи; опубликовано UserAccessChangedKafkaEvent с
полным списком активных назначений пользователя (старые + новое); кэш perm:{UserId} инвалидирован.
Эффективные права пользователя расширились немедленно в источнике и eventually — у потребителей прав.
Постусловия при отказе. Назначение не создано, событие не публикуется, права не изменились; при повторе
с тем же Idempotency-Key или дубле по тройке исход идемпотентен (существующее назначение не дублируется).
Алгоритм
- Контекст и доступ. Операцию выполняет пользователь компании; tenant — его компания (
CompanyId); требуется правоidentity.role-assignments.manage. По INV-R8 (GranterAuthority) это право актора должно быть выдано в scope, покрывающем выдаваемый, по иерархииPlatform ⊃ Organization ⊃ ProjectGroup ⊃ Project(Organization(CompanyId)покрывает всё; актор с manage только вProject(X)может выдавать только вProject(X)); нет покрытия →FORBIDDEN. Повтор с тем жеIdempotency-Keyвозвращает сохранённый результат. - Проверка входа и предусловий.
- Форма scope (INV-R4): комбинация
ScopeType/ProjectGroupId/ProjectIdстрого по VOResourceScope; иначе —ValidationError.ScopeType == Platform→IDENTITY_ASSIGNMENT_PLATFORM_SCOPE_FORBIDDEN(INV-R3, платформенные scope — только администратор платформы). - Целевой пользователь существует и принадлежит компании актора; иначе —
IDENTITY_USER_NOT_FOUND. Его статусInvited/Active;Blocked/Archivedне получают ролей →IDENTITY_USER_INVALID_STATUS_TRANSITION. - Роль существует и в статусе
Active; иначе —IDENTITY_ROLE_NOT_FOUND/IDENTITY_ROLE_ARCHIVED. - Соответствие роли компании (INV-R2): роль компании допускается только своя (
role.CompanyId == CompanyId); системная роль — лишь организационная (Code ∈ {agent, agency-owner, developer-manager}) и совместимая сcompany.Type(developer-manager— толькоDeveloper;agent/agency-owner—Agency/Broker); иначе —IDENTITY_ASSIGNMENT_COMPANY_MISMATCH. - Существование scope-ресурсов: при
ProjectGroup/Projectпроверяется наличие и принадлежность компании через internal-APIchessboardGET /internal/v1/projects/{id}(timeout 2 с, retry ×1); не найден или чужой →IDENTITY_SCOPE_RESOURCE_NOT_FOUND; недоступность chessboard →SERVICE_UNAVAILABLE(503; проверка на write-path обязательна, консистентность далее — eventual). - Дубль (INV-R6): активное назначение на ту же тройку (пользователь, роль, scope) уже есть →
IDENTITY_ASSIGNMENT_DUPLICATE. ExpiresAt, если задан, — в будущем; иначе —ValidationError.
- Форма scope (INV-R4): комбинация
- Что читаем. Целевого пользователя, роль, компанию актора (для проверки типа при системной роли) и активные назначения пользователя (для сборки payload события).
- Бизнес-правила и маппинг. Создаётся назначение (инварианты INV-R2/R3/R4):
UserId/RoleId— из входа,Scope— изScopeType+organizationId = CompanyId(из claims, не из body) + опциональныеProjectGroupId/ProjectId,Status = Active, фиксируются автор и момент выдачи,ExpiresAt— из входа,Version = 1. Поднимается доменный фактRoleAssignmentGrantedEvent. - Что меняется. Появляется запись назначения. Гонка параллельной выдачи того же разрешается частичным
уникальным индексом
ux_role_assignments_user_role_scope→IDENTITY_ASSIGNMENT_DUPLICATE. - Какие факты/события фиксируются.
UserAccessChangedKafkaEvent(топикpin.identity.access.v1, ключUserId) — payload:UserId,CompanyId, полный список активных назначений (старые + новое) как{RoleCode, ScopeType, ScopeIds}, версия. - Согласованность. Назначение, отметка идемпотентности и событие фиксируются в одном согласованном изменении.
- Результат. Идентификатор и параметры назначения, момент выдачи (
201). После фиксации — инвалидация кэшаperm:{UserId}.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Интеграционные события | UserAccessChangedKafkaEvent → pin.identity.access.v1, key = UserId |
| Внешние вызовы | chessboard internal API (валидация ProjectGroupId/ProjectId; timeout 2 с, retry ×1, при недоступности — 503) |
| Проекции / кэш | кэш perm:{UserId} — инвалидация после фиксации |
Интеграционная последовательность и согласованность
Синхронно (в рамках запроса): валидация scope-ресурса — вызов chessboard GET /internal/v1/projects/{id}
(timeout 2 с, retry ×1); при недоступности — SERVICE_UNAVAILABLE (503), назначение не создаётся (проверка
существования scope обязательна на write-path). После успешной фиксации назначения:
- identity инвалидирует собственный кэш
perm:{UserId}— self-запросы прав пользователя видят новое назначение сразу. - Асинхронно публикуется
UserAccessChangedKafkaEvent→ топикpin.identity.access.v1, ключUserId(порядок для одного пользователя сохраняется партиционированием поUserId). - Потребители —
crm,catalog,chessboard, gateway — сбрасывают свою копию авторизации по этому пользователю; после обработки новые права действуют на их enforcement-точках.
Между шагами 1 и 3 — секунды. Что видит пользователь в промежутке: собственный экран «мои права» и
identity-проверки уже отражают выданную роль, но действие в соседнем сервисе (напр. доступ к канбану
проекта в crm) может стать доступным с задержкой обработки события. Обратной несогласованности (роль
ещё не видна в источнике, но уже видна у потребителя) не бывает — источник меняется первым.
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
ValidationError | 400 | Форма scope (INV-R4) / ExpiresAt <= now | Список ошибок | no retry |
IDENTITY_ASSIGNMENT_PLATFORM_SCOPE_FORBIDDEN | 403 | ScopeType = Platform (INV-R3) | «Платформенные назначения — только через администратора платформы» | no retry |
IDENTITY_USER_NOT_FOUND | 404 | Пользователь не существует / чужой | «Пользователь не найден» | no retry |
IDENTITY_USER_INVALID_STATUS_TRANSITION | 409 | Пользователь Blocked/Archived | «Пользователь недоступен для назначения» | no retry |
IDENTITY_ROLE_NOT_FOUND | 404 | Роль не существует | «Роль не найдена» | no retry |
IDENTITY_ROLE_ARCHIVED | 409 | Роль Archived | «Роль архивирована» | no retry |
IDENTITY_ASSIGNMENT_COMPANY_MISMATCH | 409 | Роль другой компании / несовместимая системная (INV-R2) | «Роль не может быть назначена в этой компании» | no retry |
IDENTITY_SCOPE_RESOURCE_NOT_FOUND | 400 | Проект/группа не существует или чужой | «Ресурс области действия не найден» | no retry |
IDENTITY_ASSIGNMENT_DUPLICATE | 409 | Активный дубль (INV-R6) | «Роль уже назначена в этом scope» | no retry (идемпотентный исход) |
FORBIDDEN | 403 | INV-R8 — scope актора не покрывает целевой | «Недостаточно прав для этой области действия» | no retry |
SERVICE_UNAVAILABLE | 503 | chessboard недоступен на валидации scope | «Попробуйте позже» | retry с backoff |
Совместимость и наблюдаемость
- Additive-поля формы допустимы. Событие
pin.identity.access.v1— contract v1 (role.md). Висячие scope после удаления проекта чистит job поpin.chessboard.project-upserted.v1(role.md «Связи»). - Logs:
ActorUserId,UserId,RoleId, scope; metrics:identity_assignment_grant_total{result, scope_type}; traces: OTel (включая исходящий вызов chessboard); dashboardidentity-overview; runbook — не применяется.
Acceptance Criteria
- Given актор с manage в
Organization(CompanyId)и сотрудникActive, WhenPOSTроли компании в scopeOrganization, Then201, назначениеActive; опубликованUserAccessChangedKafkaEvent(key =UserId);perm:{UserId}инвалидирован. - Given
ScopeType = Projectи существующий проект компании, WhenPOST, Then назначение создаётся со scopeProject(P); сотрудник получает права роли только в проектеP. - Given
ScopeType = Platform, WhenPOST, ThenIDENTITY_ASSIGNMENT_PLATFORM_SCOPE_FORBIDDEN(403) — INV-R3. - Given актор с manage только в
Project(X), When выдаёт назначение в scopeOrganization, ThenFORBIDDEN(403) — INV-R8: scope актора не покрывает выдаваемый. - Given
ScopeType = ProjectбезProjectId(или лишнийProjectGroupId), WhenPOST, ThenValidationError(400) — INV-R4. - Given
RoleId— роль чужой компании или несовместимая системная, WhenPOST, ThenIDENTITY_ASSIGNMENT_COMPANY_MISMATCH(409) — INV-R2. - Given
ProjectIdне найден вchessboardили чужой, WhenPOST, ThenIDENTITY_SCOPE_RESOURCE_NOT_FOUND(400). - Given internal-API
chessboardне отвечает после retry, WhenPOSTсо scopeProject/ProjectGroup, ThenSERVICE_UNAVAILABLE(503), назначение не создано. - Given активное назначение на ту же тройку уже есть, When
POST, ThenIDENTITY_ASSIGNMENT_DUPLICATE(409); повтор с тем жеIdempotency-Keyвозвращает первый результат. - Given целевой пользователь
Blocked/Archived, WhenPOST, ThenIDENTITY_USER_INVALID_STATUS_TRANSITION(409). - Given
ExpiresAtв будущем, WhenPOST, Then назначение создаётся с этим сроком; по наступленииExpiresAtbackground-job переведёт его вExpired(вне этой операции).
Обратные ссылки
Автоссылки: где используется этот документ.
- API (5): GET /api/v1/companies/my/users — Список пользователей компании, Identity Company-User API — реестр методов и общие правила области, POST /api/v1/companies/my/users/invitations — Приглашение пользователя в компанию, POST /api/v1/role-assignments/{assignmentId}/revoke — Отзыв назначения роли, POST /api/v1/roles — Создание роли компании
- Use Cases (2): UC-IDN-005. Создание роли компании и назначение агенту в scope проекта, UC-SUP-004. Ручное вмешательство через permissions
- Фоновые задачи (1): Background Job: AccessFanOutJob — веерная рассылка UserAccessChangedKafkaEvent при смене состава прав роли
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущность: domain/role.md (
RoleAssignment,ResourceScope, INV-R2..R8). - Отзыв: revoke-role-assignment.md; проверка покрытия: check-permission.md.