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

POST /api/v1/me/active-agency — Выбор активного агентства

Агент с несколькими активными членствами выбирает, в контексте какого агентства он сейчас действует (ADR-0070). Операция устанавливает durable-указатель RefreshSession.ActiveCompanyId, синхронизирует зеркало UserEntity.CompanyId и переиздаёт access-JWT с claim tenant_id = ActiveCompanyId (контракт ADR-0052 TenantId = CompanyId) — смена агентства действует немедленно. Уже созданные CRM-ресурсы не трогаются: их снапшот AgencyCompanyId/OriginAgencyCompanyId иммутабелен.

Назначение

Явный, серверный выбор активного агентства при мульти-членстве. Клиент вызывает операцию:

  • после логина/refresh, когда пришёл nextAction = SelectActiveAgency (у агента > 1 активного членства и активное агентство ещё не выбрано либо выбранное перестало быть активным — увольнение, ADR-0072);
  • при явном переключении между агентствами из личного кабинета.

Выбор durable — переживает refresh и повторные входы (хранится на refresh-сессии, а не только в короткоживущем access-токене); последующий refresh продолжает выводить tenant_id из ActiveCompanyId. Owning-контекст — identity. Дом полей сессии — domain/user-session.md (RefreshSession.ActiveCompanyId).

Metadata

ПолеЗначение
Сервис/контекстidentity
API areame
Feature groupactive-agency
Статусdraft
Документdocs/06-services/identity/api/me/set-active-agency.md

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

ПроверкаЗначение
Actor typeUser (агент с ≥ 1 активным членством)
Auth policy / Permissionsauthenticated; self-действие (UserId из claim sub)
Scope (RBAC)self; выбор ограничен активными членствами самого актора
Record-levelсобственная сессия (sid из access-JWT) и собственные активные AgentMembershipEntity (по AgentUserId)
Tenant isolationрезультат меняет claim tenant_id актора; выбор валидируется против активных членств — задать чужой/неактивный tenant нельзя
Auditupdate RefreshSession (ActiveCompanyId); update user (зеркало CompanyId); reissue access-JWT
MFA/approvalне требуется

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/v1/me/active-agency
Success status200
Idempotency headerне требуется (повтор с тем же CompanyId — идемпотентный 200, ActiveCompanyId уже равен целевому)
CorrelationOpenTelemetry trace (traceparent)

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
ActorUserIdidentity claims (sub)GuidДавалидный JWT401 без токена
SessionIdidentity claims (sid)GuidДасессия существует и Activeсессия актора by construction
CompanyIdbodyGuidДанепустой Guid; должен быть среди активных членств акторавыбор валидируется на сервере (заголовочный tenant отвергнут, ADR-0070)

Тело запроса: { "CompanyId": "<Guid>" }.

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

ПолеТипОбязательностьИсточникMaskingОписание
ActiveCompanyIdGuidДаRefreshSession.ActiveCompanyIdВыбранное активное агентство (= CompanyId запроса).
CompanyNamestringДаCompanyEntity.Name активного агентстваОтображаемое имя выбранного агентства.
Rolestring (enum AgentMembershipRole)ДаAgentMembershipEntity.Role в активном членствеРоль агента в выбранном агентстве (напр. Agent / Lead).
MembershipsActiveAgencyMembershipItem[]Даактивные AgentMembershipEntity актораПолный список активных членств для рендера переключателя агентств.
AccessTokenJwtTokenModelДапереизданный JWTне логируетсяНовый access-JWT с claim tenant_id = ActiveCompanyId ({token, notBefore, type, expirationDate}, user-session.md); смена агентства действует немедленно, без отдельного refresh.

ActiveAgencyMembershipItem

ПолеТипОбязательностьИсточникОписание
CompanyIdGuidДаAgentMembershipEntity.CompanyIdАгентство членства.
CompanyNamestringДаCompanyEntity.NameИмя агентства.
Rolestring (enum AgentMembershipRole)ДаAgentMembershipEntity.RoleРоль агента в этом агентстве.
IsActiveboolДавычисляетсяtrue для агентства, совпадающего с выбранным ActiveCompanyId.

Алгоритм

  1. Контекст и доступ. Операцию выполняет сам агент (ActorUserId из claim sub, SessionId из claim sid); без токена — 401.
  2. Проверка входа и предусловий.
    • CompanyId непустой Guid — иначе ValidationError (400);
    • учётка ActorUserId существует и в статусе Active — иначе IDENTITY_USER_BLOCKED (400);
    • тип учётки — Agent (у инвестора нет членств) — иначе IDENTITY_NOT_AN_AGENT (409);
    • целевое CompanyId — среди активных членств актора (AgentMembershipEntity со Status = Active по AgentUserId); нет такого активного членства → IDENTITY_MEMBERSHIP_NOT_ACTIVE (404);
    • агентство целевого членства (CompanyEntity) в статусе Active — иначе IDENTITY_COMPANY_SUSPENDED (409).
  3. Что читаем. Учётка агента; его активные членства (Status = Active, инвариант SingleActivePerCompany — не более одной активной записи на пару (AgentUserId, CompanyId), ADR-0070); компании этих членств (имена, статусы); текущая refresh-сессия по SessionId.
  4. Бизнес-правила и переходы.
    • RefreshSession.ActiveCompanyId = CompanyId (durable-выбор; переживает refresh);
    • зеркало UserEntity.CompanyId = CompanyId (семантика «текущее выбранное» агентство, ADR-0070 п.4; INV-S9 user-session.md);
    • переиздаётся access-JWT: sub = ActorUserId, sid = SessionId, tenant_id = ActiveCompanyId, TTL как у обычного access-токена (permissions в JWT не кладутся — авторизация через check-permission);
    • снятие nextAction = SelectActiveAgency: после выбора клиент допускается к CRM-ресурсам агентства.
  5. Что меняется. Обновляются RefreshSession.ActiveCompanyId и зеркало UserEntity.CompanyId; выпускается новый access-JWT. Изменения и (при смене зеркала) публикация события — в одном согласованном изменении.
  6. События. При фактической смене зеркала UserEntity.CompanyId публикуется user-upserted в pin.identity.users.v1 (CompanyId = ActiveCompanyId) — downstream видит текущее агентство агента. Отдельного события «сменил активное агентство» нет; выбор — состояние сессии. Refresh-токен не ротируется (сессия та же), поэтому событие входа не публикуется.
  7. Результат. ActiveAgencyResult: ActiveCompanyId, CompanyName, Role (в выбранном агентстве), Memberships[] (все активные членства с флагом IsActive), AccessToken (новый claim tenant_id) → 200.
CRM-ресурсы не пересчитываются

Уже созданные лиды/сделки/фиксации не пересчитываются — их снапшот AgencyCompanyId/OriginAgencyCompanyId иммутабелен (ADR-0061 п.4, ADR-0070 п.4).

Диаграмма

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

ТипДетали
Integration eventsпри смене зеркала — user-upserted → pin.identity.users.v1 (CompanyId = ActiveCompanyId)
External callsnone
Cache / projections / searchновый claim tenant_id определяет tenant последующих CRM create-* операций; historical-снапшоты не трогаются
Notificationsnone

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
ValidationError400Пустой/некорректный CompanyId«Некорректный запрос»no retry
IDENTITY_USER_BLOCKED400user.Status != Active«Учётная запись заблокирована»no retry
IDENTITY_NOT_AN_AGENT409user.Type != Agent (нет членств)«Вы не являетесь агентом»no retry
IDENTITY_MEMBERSHIP_NOT_ACTIVE404CompanyId не среди активных членств актора«Активное членство не найдено»no retry
IDENTITY_COMPANY_SUSPENDED409Агентство целевого членства не Active«Агентство заблокировано»no retry
CONCURRENCY_CONFLICT409Гонка версий сессии/учётки (параллельный refresh/leave)«Данные изменены, повторите»retry после перечитки

Acceptance Criteria

  • Given агент с ≥ 2 активными членствами и nextAction = SelectActiveAgency, When POST /me/active-agency с валидным CompanyId из активных членств, Then 200; RefreshSession.ActiveCompanyId = CompanyId, зеркало UserEntity.CompanyId = CompanyId, выпущен новый access-JWT с tenant_id = CompanyId; ответ содержит ActiveCompanyId/CompanyName/Role и Memberships[] с IsActive = true у выбранного; nextAction снят.
  • Given уже выбранное активное агентство, When повтор с тем же CompanyId, Then идемпотентный 200 (ActiveCompanyId уже равен целевому), выдан свежий access-JWT.
  • Given CompanyId не входит в активные членства актора (не член / уволен / членство закрыто), When POST /me/active-agency, Then IDENTITY_MEMBERSHIP_NOT_ACTIVE (404).
  • Given агентство целевого членства не Active, When POST /me/active-agency, Then IDENTITY_COMPANY_SUSPENDED (409).
  • Given актор — инвестор (нет членств), When POST /me/active-agency, Then IDENTITY_NOT_AN_AGENT (409).
  • Given учётка Blocked, When POST /me/active-agency, Then IDENTITY_USER_BLOCKED (400).
  • Given параллельный refresh/leave меняет сессию, When выбор конфликтует по версии, Then CONCURRENCY_CONFLICT (409); retry после перечитки.
  • Given выбор выполнен, Then ранее созданные CRM-ресурсы не пересчитываются (снапшоты AgencyCompanyId/OriginAgencyCompanyId иммутабельны); последующий refresh продолжает выводить tenant_id из ActiveCompanyId (durable); последующие CRM create-* берут агентство из этого claim.

Совместимость и наблюдаемость

  • Новый эндпоинт, additive (ADR-0070). Выбор активного агентства — серверный, валидируется против активных членств; заголовочный tenant отвергнут.
  • Logs: ActorUserId, SessionId, ActiveCompanyId (токен — никогда); metrics: identity_set_active_agency_total{result}; traces: OTel.

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

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

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