POST /api/v1/companies/my/users/{userId}/block — Блокировка пользователя компании
Перевод учётки сотрудника своей компании Active|Invited → Blocked с отзывом всех refresh-сессий
(INV-S7 user-session.md). Обратная операция unblock — симметричная (см. «Совместимость»).
Назначение
Руководитель блокирует сотрудника (увольнение, инцидент): вход и все действующие сессии прекращаются,
исторические данные (сделки, фиксации) сохраняются. Owning-контекст — identity. Feature group —
companies.
| Поле | Значение |
|---|
| Сервис/контекст | identity |
| API area | public |
| Feature group | companies |
| Статус | draft |
| Документ | docs/06-services/identity/api/company-user/block-company-user.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (пользователь компании) |
| Auth policy / Permissions | identity.users.block (дефолт: agency-owner; admin — через admin-API, ADR-0053) |
| Scope (RBAC) | организация: Active RoleAssignment со scope Platform или Organization(CompanyId) |
| Record-level | целевой пользователь обязан принадлежать компании актора (target.CompanyId == CompanyId) |
| Tenant isolation | TenantId = CompanyId из claims; интерсептор INullableTenantedEntity дополнительно отсекает чужих |
| Audit | update: UpdatedByUserId, Version, события UserBlockedKafkaEvent + SessionRevokedEvent — привилегированная операция, полностью аудируема |
| MFA/approval | не требуется (в границах организации); самоблокировка запрещена (шаг 2) |
HTTP-контракт
| Поле | Значение |
|---|
| Method | POST |
| Route | /api/v1/companies/my/users/{userId}/block |
| Success status | 200 |
| Idempotency header | не применяется — повтор по уже заблокированному возвращает IDENTITY_USER_INVALID_STATUS_TRANSITION (безопасно) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
ActorUserId | identity claims | Guid | Да | Required | — |
CompanyId | identity claims (tenant_id) | Guid | Да | Required | Tenant isolation |
UserId | route {userId} | Guid | Да | Required, != Guid.Empty; != ActorUserId (самоблокировка) | — |
Reason | body | string? | Нет | <= 512 симв.; свободный текст для аудита | не отдаётся наружу, только audit-лог |
Модель ответа BlockCompanyUserCommandResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
UserId | Guid | Да | UserEntity.Id | — | Кто заблокирован. |
Status | string (enum UserStatus) | Да | UserEntity.Status | — | Всегда Blocked. |
RevokedSessionsCount | int | Да | число отозванных RefreshSession | — | Сколько сессий отозвано. |
BlockedAt | DateTimeOffset | Да | UserEntity.UpdatedAt | — | Момент блокировки. |
Алгоритм
- Контекст и доступ. Операцию выполняет пользователь компании; tenant — его компания (
CompanyId);
требуется право identity.users.block в scope Organization(CompanyId).
- Проверка входа и предусловий.
UserId непустой, Reason ≤ 512 символов; иначе — ValidationError.
- Самоблокировка запрещена:
UserId == ActorUserId → IDENTITY_USER_SELF_BLOCK_FORBIDDEN.
- Целевой пользователь существует и принадлежит компании актора (
target.CompanyId == CompanyId);
иначе — IDENTITY_USER_NOT_FOUND.
- Статус пользователя допускает блокировку:
Active или Invited; Blocked/Archived →
IDENTITY_USER_INVALID_STATUS_TRANSITION.
- Что читаем. Целевого пользователя (для проверок выше). Активные refresh-сессии в память не
загружаются — отзываются массовым обновлением на шаге 5.
- Бизнес-правила и переходы. Пользователь переходит
Active|Invited → Blocked: фиксируются момент и
автор блокировки; поднимается доменный факт UserBlockedEvent. Назначения ролей (RoleAssignment) не
отзываются — блокировка обратима, назначения ждут разблокировки; вход запрещается по статусу
(инвариант IDENTITY_USER_BLOCKED, user.md).
- Что меняется. Обновляется учётка пользователя; все его активные refresh-сессии массово переводятся
в
Revoked с причиной AdminBlock (INV-S7), без загрузки в память; число затронутых строк →
RevokedSessionsCount.
- Какие факты/события фиксируются.
UserBlockedKafkaEvent (топик pin.identity.users.v1, ключ UserId) — Id, BlockedAt, причина
в виде enum-кода (без свободного текста);
SessionRevokedEvent — доменный факт на каждую логическую пачку отозванных сессий (лента
расследований, user-session.md);
UserAccessChangedKafkaEvent не публикуется: назначения не изменились; потребители обязаны
проверять Status пользователя из pin.identity.users.v1.
- Согласованность. Учётка, батч сессий и события фиксируются в одном согласованном изменении.
Access-JWT, выданные до блокировки, живут до истечения (≤ 15 мин): на привилегированных операциях
gateway дополнительно вызывает check-permission, который вернёт
отказ по
Status = Blocked.
- Результат. Идентификатор пользователя, статус
Blocked, число отозванных сессий, момент
блокировки. Кэш эффективных прав (perm:{userId}) инвалидируется после фиксации (best-effort).
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Интеграционные события | UserBlockedKafkaEvent → pin.identity.users.v1 (потребители: crm, messenger, notifications, user-activity) |
| Проекции / кэш | кэш эффективных прав perm:{userId} — инвалидация после фиксации (best-effort) |
| Уведомления | identity сам не уведомляет; notifications может уведомить по событию |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
ValidationError | 400 | Пустой UserId / длинный Reason | Список ошибок | no retry |
IDENTITY_USER_SELF_BLOCK_FORBIDDEN | 409 | UserId == ActorUserId | «Нельзя заблокировать себя» | no retry |
IDENTITY_USER_NOT_FOUND | 404 | Пользователь не существует / чужой компании | «Пользователь не найден» | no retry |
IDENTITY_USER_INVALID_STATUS_TRANSITION | 409 | Status ∈ {Blocked, Archived} | «Пользователь уже заблокирован или архивирован» | no retry |
IDENTITY_CONCURRENCY_CONFLICT | 409 | Конфликт оптимистичной блокировки | «Повторите операцию» | retry |
FORBIDDEN | 403 | Нет identity.users.block | «Недостаточно прав» | no retry |
Acceptance Criteria
- Given сотрудник компании
Active с активными сессиями, When руководитель с
identity.users.block блокирует его, Then статус Active|Invited → Blocked, все refresh-сессии
переходят Revoked (AdminBlock), ответ содержит RevokedSessionsCount и BlockedAt, публикуется
UserBlockedKafkaEvent (key = UserId); назначения ролей НЕ отзываются (блокировка обратима).
- Given
UserId == ActorUserId, When block, Then IDENTITY_USER_SELF_BLOCK_FORBIDDEN (409).
- Given целевой пользователь другой компании, When block, Then
IDENTITY_USER_NOT_FOUND (404)
— cross-tenant не раскрывается.
- Given пользователь уже
Blocked или Archived, When block, Then
IDENTITY_USER_INVALID_STATUS_TRANSITION (409).
- Given
Reason длиннее 512 символов, When block, Then ValidationError (400).
- Given пользователь заблокирован, у него живой access-JWT (≤ 15 мин), When привилегированная
операция, Then gateway вызывает check-permission → отказ по
Status = Blocked; вход любым способом запрещён немедленно.
- Given
UserAccessChangedKafkaEvent не публикуется (назначения не менялись), Then потребители
сверяют Status пользователя из pin.identity.users.v1 (иначе краткий промежуток считают его активным).
- Given нет
identity.users.block в scope организации, When block, Then FORBIDDEN (403).
Совместимость и наблюдаемость
- Обратная операция
POST /api/v1/companies/my/users/{userId}/unblock — зеркальная: guard Blocked → Active, событие UserUpsertedKafkaEvent (snapshot со Status = Active), сессии не восстанавливаются.
Документируется тем же контрактом (дельта — только переход).
- Logs:
ActorUserId, UserId, RevokedSessionsCount (без Reason в общих логах — только audit);
metrics: identity_user_block_total{result}; traces: OTel; dashboard identity-overview;
runbook: массовый отзыв сессий — README identity.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы