POST /api/v1/role-assignments/{assignmentId}/revoke — Отзыв назначения роли
Перевод RoleAssignment Active → Revoked (stateDiagram role.md). Запись не удаляется — полная
история назначений сохраняется для аудита доступов (role.md «Audit»).
Назначение
Руководитель снимает с сотрудника роль (смена обязанностей, сужение доступа). Права пересчитываются
немедленно; consumers получают UserAccessChangedKafkaEvent. Owning-контекст — identity. Feature
group — role-assignments.
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | identity |
| API area | public |
| Feature group | role-assignments |
| Статус | draft |
| Документ | docs/06-services/identity/api/company-user/revoke-role-assignment.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (пользователь компании) |
| Auth policy / Permissions | identity.role-assignments.manage |
| Scope (RBAC) | INV-R8: permission актора обязан покрывать scope отзываемого назначения (иерархия Platform ⊃ Organization ⊃ ProjectGroup ⊃ Project) |
| Record-level | назначение обязано принадлежать компании актора (assignment.CompanyId == CompanyId) |
| Tenant isolation | TenantId = CompanyId; платформенные назначения (CompanyId = null) недостижимы этим методом — только администратор платформы (ADR-0053) |
| Audit | update: RevokedByUserId = actor, RevokedAt; запись остаётся навсегда |
| MFA/approval | не требуется; защита от самоликвидации последнего админа (шаг 2) |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /api/v1/role-assignments/{assignmentId}/revoke |
| Success status | 200 |
| Idempotency header | не применяется — повтор по Revoked возвращает IDENTITY_ASSIGNMENT_ALREADY_REVOKED |
| Correlation | сквозной trace через заголовок traceparent |
Отзыв — доменный переход статуса с причиной и аудитом, а не удаление ресурса, поэтому семантика выбрана
POST-действием, а не DELETE /role-assignments/{id}.
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
ActorUserId | identity claims | Guid | Да | Required | — |
CompanyId | identity claims (tenant_id) | Guid | Да | Required | Tenant isolation |
AssignmentId | route {assignmentId} | Guid | Да | != Guid.Empty | — |
Reason | body | string? | Нет | <= 512; свободный текст для аудита | не отдаётся наружу |
Модель ответа RevokeRoleAssignmentCommandResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
AssignmentId | Guid | Да | RoleAssignment.Id | — | Id назначения. |
UserId | Guid | Да | RoleAssignment.UserId | — | У кого отозвано. |
Status | string (enum RoleAssignmentStatus) | Да | RoleAssignment.Status | — | Всегда Revoked. |
RevokedAt | DateTimeOffset | Да | RoleAssignment.RevokedAt | — | Момент отзыва. |
Предусловия и постусловия
Предусловия. Актор аутентифицирован и имеет identity.role-assignments.manage в scope, покрывающем
scope отзываемого назначения (INV-R8); назначение существует, принадлежит компании актора и в статусе
Active; отзыв не оставляет организацию без единственного администратора (guard последнего админа).
Постусловия при успехе. Назначение переведено Active → Revoked (запись не удаляется — история
сохраняется), зафиксированы момент, автор и причина; опубликовано UserAccessChangedKafkaEvent с
пересобранным списком оставшихся активных назначений; кэш perm:{UserId} инвалидирован. Эффективные права
пользователя сузились немедленно в источнике и eventually — у потребителей.
Постусловия при отказе. Статус назначения не изменился, событие не публикуется.
Алгоритм
- Контекст и доступ. Операцию выполняет пользователь компании; tenant — его компания (
CompanyId); требуется правоidentity.role-assignments.manage. По INV-R8 это право актора должно покрывать scope отзываемого назначения по иерархииPlatform ⊃ Organization ⊃ ProjectGroup ⊃ Project(актор с manage толькоProject(X)не может отозвать назначение scopeOrganization); нет покрытия →FORBIDDEN. - Проверка входа и предусловий.
AssignmentIdнепустой,Reason ≤ 512символов; иначе —ValidationError.- Назначение существует и принадлежит компании актора (
assignment.CompanyId == CompanyId); чужое/платформенное →IDENTITY_ASSIGNMENT_NOT_FOUND. - Назначение в статусе
Active;Revoked→IDENTITY_ASSIGNMENT_ALREADY_REVOKED;Expired→IDENTITY_ASSIGNMENT_EXPIRED. - Защита последнего админа: если актор отзывает у себя (
assignment.UserId == ActorUserId) последний источникidentity.role-assignments.manageв организации →IDENTITY_ROLE_LAST_ADMIN_PROTECTION(вычисляется по оставшимся назначениям, шаг 3).
- Что читаем. Назначение; его роль (для
RoleCodeв payload события); оставшиеся активные назначения пользователя (для payload события и guard-а последнего админа). - Бизнес-правила и переходы. Назначение переходит
Active → Revokedс причинойManualRevoke: фиксируются момент и автор отзыва. Поднимается доменный фактRoleAssignmentRevokedEvent. - Что меняется. Обновляется запись назначения (не удаляется — история сохраняется). Конкурентный
конфликт (параллельный отзыв/экспирация джобой) →
IDENTITY_CONCURRENCY_CONFLICT. - Какие факты/события фиксируются.
UserAccessChangedKafkaEvent(топикpin.identity.access.v1, ключUserId) — payload:UserId,CompanyId, список оставшихся активных назначений (уже без отозванного) как{RoleCode, ScopeType, ScopeIds}, версия. - Согласованность. Назначение и событие фиксируются в одном согласованном изменении.
- Результат. Идентификатор назначения, пользователь, статус
Revoked, момент отзыва (200). После фиксации — инвалидация кэшаperm:{UserId}.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Интеграционные события | UserAccessChangedKafkaEvent → pin.identity.access.v1, key = UserId |
| Проекции / кэш | кэш perm:{UserId} — инвалидация после фиксации |
Интеграционная последовательность и согласованность
После фиксации перехода Active → Revoked:
- identity инвалидирует
perm:{UserId}— self-запрос прав пользователя сразу не показывает отозванную роль. - Асинхронно публикуется
UserAccessChangedKafkaEvent→ топикpin.identity.access.v1, ключUserId— payload содержит список оставшихся активных назначений (без отозванного). - Потребители авторизации (
crm,catalog,chessboard, gateway) сбрасывают свою копию по этому пользователю; после обработки отзыв действует на их enforcement-точках.
Окно между шагами 1→3 — секунды. Риск промежутка: до обработки события downstream-сервис может краткий момент всё ещё пропускать действие по уже отозванному праву (устаревший кэш авторизации) — это допустимо для целевой нагрузки; критичные операции проверяют доступ на источнике/по свежему событию. Обратной несогласованности (право уже вернулось у потребителя, но ещё активно в источнике) не бывает — источник меняется первым.
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
ValidationError | 400 | Пустой id / длинный Reason | Список ошибок | no retry |
IDENTITY_ASSIGNMENT_NOT_FOUND | 404 | Назначение не существует / чужое / платформенное | «Назначение не найдено» | no retry |
IDENTITY_ASSIGNMENT_ALREADY_REVOKED | 409 | Status = Revoked | «Назначение уже отозвано» | no retry (идемпотентный исход) |
IDENTITY_ASSIGNMENT_EXPIRED | 409 | Status = Expired | «Назначение истекло» | no retry |
IDENTITY_ROLE_LAST_ADMIN_PROTECTION | 409 | Актор лишает себя последнего manage-права | «Нельзя отозвать собственное последнее административное назначение» | no retry |
IDENTITY_CONCURRENCY_CONFLICT | 409 | Конфликт оптимистичной блокировки | «Повторите операцию» | retry |
FORBIDDEN | 403 | INV-R8 — scope актора не покрывает scope назначения | «Недостаточно прав для этой области действия» | no retry |
Совместимость и наблюдаемость
- Контракт стабилен; enum-причина в событии — additive-расширяемая (unknown → «прочее» у consumers).
- Logs:
ActorUserId,AssignmentId,UserId; metrics:identity_assignment_revoke_total{result}; traces: OTel; dashboardidentity-overview; runbook — не применяется.
Acceptance Criteria
- Given активное назначение своей компании и актор с покрывающим
identity.role-assignments.manage, WhenPOST /revoke, Then200; назначениеRevoked,RevokedAtзаполнен, запись сохранена; опубликованUserAccessChangedKafkaEventс оставшимися назначениями;perm:{UserId}инвалидирован. - Given назначение уже
Revoked, When повторныйPOST /revoke, ThenIDENTITY_ASSIGNMENT_ALREADY_REVOKED(409). - Given назначение
Expired, WhenPOST /revoke, ThenIDENTITY_ASSIGNMENT_EXPIRED(409). - Given актор с manage только в
Project(X), When отзывает назначение scopeOrganization, ThenFORBIDDEN(403) — INV-R8. - Given назначение другой компании или платформенное (
CompanyId = null), WhenPOST /revoke, ThenIDENTITY_ASSIGNMENT_NOT_FOUND(404). - Given актор отзывает у себя последний источник
identity.role-assignments.manageв организации, WhenPOST /revoke, ThenIDENTITY_ROLE_LAST_ADMIN_PROTECTION(409); назначение остаётсяActive. - Given параллельно джоба переводит назначение в
Expired, WhenPOST /revokeпроигрывает гонку версий, ThenIDENTITY_CONCURRENCY_CONFLICT(409) с рекомендацией повторить. - Given задан
Reason, WhenPOST /revoke, Then причина сохраняется для аудита и наружу в ответе не отдаётся.
Обратные ссылки
Автоссылки: где используется этот документ.
- API (3): Identity Company-User API — реестр методов и общие правила области, POST /api/v1/role-assignments — Назначение роли пользователю в scope, POST /api/v1/roles/{roleId}/archive — Архивирование роли компании
- Use Cases (2): UC-IDN-005. Создание роли компании и назначение агенту в scope проекта, UC-SUP-004. Ручное вмешательство через permissions
- Фоновые задачи (1): Background Job: AccessFanOutJob — веерная рассылка UserAccessChangedKafkaEvent при смене состава прав роли
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущность: domain/role.md (stateDiagram, INV-R6/R8).
- Выдача: grant-role-assignment.md; каскадный отзыв: archive-company-role.md.
- Экспирация по
ExpiresAt— background-jobPin.Identity.Background(роль job-документа — вне этого файла).