Перейти к основному содержимому

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 areapublic
Feature grouprole-assignments
Статусdraft
Документdocs/06-services/identity/api/company-user/grant-role-assignment.md

Актор и доступ

ПроверкаЗначение
Actor typeUser (пользователь компании)
Auth policy / Permissionsidentity.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 isolationTenantId = CompanyId; Scope.OrganizationId обязан равняться CompanyId (кросс-tenant выдача невозможна)
Auditcreate: GrantedByUserId = actor, GrantedAt; полная история назначений (записи не удаляются — role.md)
MFA/approvalне требуется; эскалация ограничена INV-R2/R3/R8

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/v1/role-assignments
Success status201
Idempotency headerIdempotency-Key (Guid); дополнительная естественная идемпотентность — INV-R6 (дубль Active-назначения невозможен)
Correlationсквозной trace через заголовок traceparent

Входные данные / параметры запроса

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
ActorUserIdidentity claimsGuidДаRequired
CompanyIdidentity claims (tenant_id)GuidДаRequiredTenant isolation
IdempotencyKeyheader Idempotency-KeyGuidДаRequired
UserIdbodyGuidДасуществующий пользователь компании актора
RoleIdbodyGuidДасуществующая Active-роль: этой компании (INV-R2) или системная организационная (agent/agency-owner/developer-manager)
ScopeTypebodystring (enum ResourceScopeType)ДаOrganization / ProjectGroup / Project; Platform запрещён (INV-R3 — только администратор платформы)
ProjectGroupIdbodyGuid?Обязателен при ScopeType = ProjectGroup, иначе запрещён (INV-R4)id группы проектов из chessboard; существование — API chessboard (шаг 2)
ProjectIdbodyGuid?Обязателен при ScopeType = Project, иначе запрещён (INV-R4)id проекта из chessboard; существование + принадлежность компании — API chessboard
ExpiresAtbodyDateTimeOffset?Нет> now; временный доступ (job переведёт в Expired)
примечание
Почему нет OrganizationId в body

OrganizationId в body отсутствует: всегда берётся из claims (CompanyId) — предотвращает кросс-tenant подмену.

Модель ответа GrantRoleAssignmentCommandResult

ПолеТипОбязательностьИсточникMaskingОписание
AssignmentIdGuidДаRoleAssignment.IdId назначения (для отзыва).
UserIdGuidДаRoleAssignment.UserIdКому выдано.
RoleCodestringДаRoleDefinition.CodeКод роли.
ScopeTypestringДаResourceScope.TypeИтоговый scope.
ScopeOrganizationIdGuidДаResourceScope.OrganizationIdОрганизация scope.
ScopeProjectGroupIdGuid?НетResourceScope.ProjectGroupIdГруппа.
ScopeProjectIdGuid?НетResourceScope.ProjectIdПроект.
ExpiresAtDateTimeOffset?НетRoleAssignment.ExpiresAtСрок.
GrantedAtDateTimeOffsetДа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 или дубле по тройке исход идемпотентен (существующее назначение не дублируется).

Алгоритм

  1. Контекст и доступ. Операцию выполняет пользователь компании; tenant — его компания (CompanyId); требуется право identity.role-assignments.manage. По INV-R8 (GranterAuthority) это право актора должно быть выдано в scope, покрывающем выдаваемый, по иерархии Platform ⊃ Organization ⊃ ProjectGroup ⊃ Project (Organization(CompanyId) покрывает всё; актор с manage только в Project(X) может выдавать только в Project(X)); нет покрытия → FORBIDDEN. Повтор с тем же Idempotency-Key возвращает сохранённый результат.
  2. Проверка входа и предусловий.
    • Форма scope (INV-R4): комбинация ScopeType/ProjectGroupId/ProjectId строго по VO ResourceScope; иначе — ValidationError. ScopeType == PlatformIDENTITY_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-ownerAgency/Broker); иначе — IDENTITY_ASSIGNMENT_COMPANY_MISMATCH.
    • Существование scope-ресурсов: при ProjectGroup/Project проверяется наличие и принадлежность компании через internal-API chessboard GET /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.
  3. Что читаем. Целевого пользователя, роль, компанию актора (для проверки типа при системной роли) и активные назначения пользователя (для сборки payload события).
  4. Бизнес-правила и маппинг. Создаётся назначение (инварианты INV-R2/R3/R4): UserId/RoleId — из входа, Scope — из ScopeType + organizationId = CompanyId (из claims, не из body) + опциональные ProjectGroupId/ProjectId, Status = Active, фиксируются автор и момент выдачи, ExpiresAt — из входа, Version = 1. Поднимается доменный факт RoleAssignmentGrantedEvent.
  5. Что меняется. Появляется запись назначения. Гонка параллельной выдачи того же разрешается частичным уникальным индексом ux_role_assignments_user_role_scopeIDENTITY_ASSIGNMENT_DUPLICATE.
  6. Какие факты/события фиксируются. UserAccessChangedKafkaEvent (топик pin.identity.access.v1, ключ UserId) — payload: UserId, CompanyId, полный список активных назначений (старые + новое) как {RoleCode, ScopeType, ScopeIds}, версия.
  7. Согласованность. Назначение, отметка идемпотентности и событие фиксируются в одном согласованном изменении.
  8. Результат. Идентификатор и параметры назначения, момент выдачи (201). После фиксации — инвалидация кэша perm:{UserId}.

Диаграмма

Побочные эффекты

ТипДетали
Интеграционные событияUserAccessChangedKafkaEventpin.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). После успешной фиксации назначения:

  1. identity инвалидирует собственный кэш perm:{UserId} — self-запросы прав пользователя видят новое назначение сразу.
  2. Асинхронно публикуется UserAccessChangedKafkaEvent → топик pin.identity.access.v1, ключ UserId (порядок для одного пользователя сохраняется партиционированием по UserId).
  3. Потребители — crm, catalog, chessboard, gateway — сбрасывают свою копию авторизации по этому пользователю; после обработки новые права действуют на их enforcement-точках.
Окно eventual-consistency

Между шагами 1 и 3 — секунды. Что видит пользователь в промежутке: собственный экран «мои права» и identity-проверки уже отражают выданную роль, но действие в соседнем сервисе (напр. доступ к канбану проекта в crm) может стать доступным с задержкой обработки события. Обратной несогласованности (роль ещё не видна в источнике, но уже видна у потребителя) не бывает — источник меняется первым.

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
ValidationError400Форма scope (INV-R4) / ExpiresAt <= nowСписок ошибокno retry
IDENTITY_ASSIGNMENT_PLATFORM_SCOPE_FORBIDDEN403ScopeType = Platform (INV-R3)«Платформенные назначения — только через администратора платформы»no retry
IDENTITY_USER_NOT_FOUND404Пользователь не существует / чужой«Пользователь не найден»no retry
IDENTITY_USER_INVALID_STATUS_TRANSITION409Пользователь Blocked/Archived«Пользователь недоступен для назначения»no retry
IDENTITY_ROLE_NOT_FOUND404Роль не существует«Роль не найдена»no retry
IDENTITY_ROLE_ARCHIVED409Роль Archived«Роль архивирована»no retry
IDENTITY_ASSIGNMENT_COMPANY_MISMATCH409Роль другой компании / несовместимая системная (INV-R2)«Роль не может быть назначена в этой компании»no retry
IDENTITY_SCOPE_RESOURCE_NOT_FOUND400Проект/группа не существует или чужой«Ресурс области действия не найден»no retry
IDENTITY_ASSIGNMENT_DUPLICATE409Активный дубль (INV-R6)«Роль уже назначена в этом scope»no retry (идемпотентный исход)
FORBIDDEN403INV-R8 — scope актора не покрывает целевой«Недостаточно прав для этой области действия»no retry
SERVICE_UNAVAILABLE503chessboard недоступен на валидации 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); dashboard identity-overview; runbook — не применяется.

Acceptance Criteria

  • Given актор с manage в Organization(CompanyId) и сотрудник Active, When POST роли компании в scope Organization, Then 201, назначение Active; опубликован UserAccessChangedKafkaEvent (key = UserId); perm:{UserId} инвалидирован.
  • Given ScopeType = Project и существующий проект компании, When POST, Then назначение создаётся со scope Project(P); сотрудник получает права роли только в проекте P.
  • Given ScopeType = Platform, When POST, Then IDENTITY_ASSIGNMENT_PLATFORM_SCOPE_FORBIDDEN (403) — INV-R3.
  • Given актор с manage только в Project(X), When выдаёт назначение в scope Organization, Then FORBIDDEN (403) — INV-R8: scope актора не покрывает выдаваемый.
  • Given ScopeType = Project без ProjectId (или лишний ProjectGroupId), When POST, Then ValidationError (400) — INV-R4.
  • Given RoleId — роль чужой компании или несовместимая системная, When POST, Then IDENTITY_ASSIGNMENT_COMPANY_MISMATCH (409) — INV-R2.
  • Given ProjectId не найден в chessboard или чужой, When POST, Then IDENTITY_SCOPE_RESOURCE_NOT_FOUND (400).
  • Given internal-API chessboard не отвечает после retry, When POST со scope Project/ProjectGroup, Then SERVICE_UNAVAILABLE (503), назначение не создано.
  • Given активное назначение на ту же тройку уже есть, When POST, Then IDENTITY_ASSIGNMENT_DUPLICATE (409); повтор с тем же Idempotency-Key возвращает первый результат.
  • Given целевой пользователь Blocked/Archived, When POST, Then IDENTITY_USER_INVALID_STATUS_TRANSITION (409).
  • Given ExpiresAt в будущем, When POST, Then назначение создаётся с этим сроком; по наступлении ExpiresAt background-job переведёт его в Expired (вне этой операции).

Обратные ссылки

Автоссылки: где используется этот документ.

Связанные документы