В корпоративном кабинете сотруднику отправляют приглашение в одну организацию, но после перехода по ссылке он оказывается в другой. Иногда пользователь видит чужое название компании сразу, иногда ошибка проявляется только после повторного входа или переключения аккаунта. Это не косметический дефект: неверная привязка tenant может открыть доступ к чужим проектам, заказам, документам и настройкам.

Исправлять такую проблему нужно не заменой organization_id в одном запросе, а проверкой всей цепочки: создание приглашения, хранение токена, авторизация пользователя, создание membership, выдача сессии и выбор активной организации на клиенте. Ниже — практический порядок диагностики и безопасная схема реализации.

Как проявляется неправильная привязка к компании

  • ссылка приглашения содержит правильную компанию, но после входа открывается последняя организация из старой сессии;
  • новый пользователь создается в нужной компании, а существующий пользователь добавляется в другую;
  • ошибка появляется только при одновременной работе в двух вкладках или двух браузерах;
  • после входа через SSO домен почты автоматически привязывает сотрудника не к тому tenant;
  • в базе membership создан правильно, но интерфейс показывает организацию из localStorage или кеша;
  • повторное открытие ссылки создает вторую связь или меняет уже принятую роль.

Сначала определите, где возникло расхождение: в таблице приглашений, в membership, в серверной сессии или только в состоянии интерфейса. От этого зависит способ исправления и масштаб проверки данных.

Почему приглашение попадает не в ту организацию

Компания берется из текущей сессии, а не из приглашения

Распространенная ошибка — принимать invitation token, но при создании membership использовать active_organization_id авторизованного пользователя. Если сотрудник до этого работал в другом кабинете, связь создается с организацией из cookie или сессии. Источником истины при принятии приглашения должна быть сама запись invitation, а не состояние браузера.

В приглашении нет неизменяемого organization_id

Если приглашение хранит только email, роль или домен, сервер вынужден заново вычислять компанию при каждом открытии ссылки. Правила маршрутизации могут измениться, домен может принадлежать нескольким организациям, а пользователь — уже состоять в другом tenant. Приглашение должно однозначно ссылаться на организацию, в которую оно создано.

Пользователь и членство смешаны в одной таблице

Поле company_id непосредственно в users работает только пока пользователь принадлежит одной компании. В multi-tenant-системе один аккаунт может состоять в нескольких организациях с разными ролями. Попытка перезаписать users.company_id при каждом приглашении приводит к потере старой связи и непредсказуемому выбору кабинета.

Токен можно принять повторно или не тем аккаунтом

Если токен не одноразовый, не имеет срока действия или не связан с нормализованным email, ссылку может принять другой авторизованный пользователь. Повторный запрос, двойной клик или retry прокси способен создать несколько memberships, если операция не идемпотентна.

Клиент восстанавливает старую активную организацию

Даже при корректной записи в базе SPA может загрузить tenant из localStorage раньше ответа профиля. Еще один источник ошибки — кеш пользователя без organization_id в ключе. В итоге сервер уже выдал новое членство, а интерфейс продолжает показывать данные предыдущей компании.

Диагностика: как найти точку подмены tenant

  1. Зафиксируйте invitation id, email приглашенного, ожидаемый organization_id, фактический tenant, user id и точное время принятия.
  2. Проверьте запись invitation до изменения данных: организацию, роль, хеш токена, срок действия, статус, автора и accepted_by.
  3. Найдите все memberships пользователя и убедитесь, что организация и роль созданы из invitation, а не из active tenant сессии.
  4. Сопоставьте журналы создания приглашения, входа, принятия и выпуска новой сессии по единому correlation id.
  5. Проверьте claims серверной сессии или JWT после принятия: список организаций, active organization и версию прав.
  6. Очистите localStorage и кеш, затем повторите тест. Если база верна, а ошибка исчезла, проблема находится в клиентском состоянии.
  7. Воспроизведите сценарий с одним email в двух организациях, старой активной сессией и двумя одновременными запросами принятия.

Не исправляйте production-данные до сохранения диагностических записей. Иначе станет невозможно понять, какие документы и действия пользователь успел выполнить в неправильной организации.

Правильная модель данных для приглашений

Учетную запись пользователя лучше отделить от членства в организации. Базовая модель состоит из трех сущностей: users хранит глобальную идентичность, organizations — компании, memberships — связь пользователя с компанией и ролью.

  • users: id, нормализованный email, данные входа и общий статус аккаунта;
  • organizations: id, название, статус и настройки tenant;
  • memberships: user_id, organization_id, role, status, created_at;
  • invitations: organization_id, email_normalized, role, token_hash, expires_at, status, invited_by, accepted_by.

На memberships нужен уникальный индекс по паре user_id и organization_id. Он защищает от дублей при повторных запросах. Сам токен приглашения следует хранить в виде хеша, как пароль: утечка базы не должна превращать все активные ссылки в готовые ключи доступа.

Как безопасно принимать приглашение

Операция должна выполняться на сервере внутри транзакции. organization_id и роль читаются только из заблокированной записи invitation. Значения tenant, role или email из формы и query string нельзя считать доверенными.

BEGIN SELECT invitation FOR UPDATE VERIFY token_hash, status, expires_at and email UPSERT membership(user_id, invitation.organization_id, invitation.role) MARK invitation accepted with accepted_by and accepted_at COMMIT REISSUE session with an allowed active organization
  1. Найдите invitation по идентификатору и сравните хеш токена безопасной функцией.
  2. Проверьте статус, срок действия и принадлежность приглашения активной организации.
  3. Если пользователь уже вошел, сравните нормализованный email. Для другого email запросите повторную авторизацию.
  4. Создайте membership идемпотентно. Повторный запрос должен вернуть тот же результат, а не вторую запись.
  5. Отметьте приглашение принятым в той же транзакции и сохраните accepted_by.
  6. После commit выпустите новую сессию и явно выберите организацию, в которую только что принят пользователь.

Если организация заблокирована или удалена между созданием и принятием приглашения, операция должна завершиться контролируемой ошибкой. Нельзя молча подставлять другую доступную компанию.

SSO и приглашения: какое правило главнее

Автоматическое присоединение по домену удобно, но не должно переопределять явное приглашение. При обработке invitation сначала фиксируется organization_id из ссылки, затем выполняется вход через нужного IdP. После callback сервер должен восстановить конкретное приглашение по защищенному state и продолжить именно его, а не заново выбирать компанию по email-домену.

Для доменов, которыми пользуются несколько компаний, автоматическое сопоставление лучше отключить или потребовать подтверждение администратора. Также проверьте, что redirect URI и state не позволяют заменить organization id на клиенте.

Как исправить уже ошибочно созданные связи

  1. Временно отзовите активные сессии затронутого пользователя и заблокируйте спорный membership.
  2. Сохраните аудит: какие страницы, записи, файлы и действия были доступны в неправильной компании.
  3. Сделайте резервную копию связанных записей перед переносом или удалением membership.
  4. Создайте правильную связь из исходного invitation и назначьте только ожидаемую роль.
  5. Не переносите автоматически документы между tenant: сначала установите владельца каждой записи и согласуйте исправление.
  6. Удалите или деактивируйте ошибочную связь, очистите tenant-кеш и выпустите новые сессии.
  7. Уведомите администраторов затронутых организаций, если был риск доступа к чужим данным.

Если пользователь успел создать сущности в чужом tenant, простого изменения membership недостаточно. У каждой записи может быть собственный organization_id, история изменений, файлы и внешние интеграции. Такой инцидент нужно разбирать как нарушение изоляции данных.

Что проверить после исправления

  • новый пользователь принимает приглашение в нужную компанию;
  • существующий пользователь с другой активной организацией получает дополнительный membership без потери старого;
  • два одновременных запроса не создают дубли и не меняют роль;
  • просроченный, отозванный и уже принятый токен отклоняется;
  • пользователь с другим email не может принять чужую ссылку;
  • после SSO сохраняется исходное приглашение и правильный tenant;
  • после выхода, повторного входа и очистки кеша активная организация остается разрешенной;
  • API проверяет membership на каждом запросе и не доверяет organization_id из интерфейса.

Добавьте интеграционные тесты минимум для двух организаций и двух пользователей. Проверка в рамках одного tenant часто не выявляет ошибку, потому что ожидаемый и текущий organization_id случайно совпадают.

Типичные ошибки при исправлении

  • перезаписывать users.company_id вместо создания отдельного membership;
  • брать активную компанию из cookie, JWT или localStorage при принятии приглашения;
  • передавать organization_id и роль в скрытых полях формы и доверять им на сервере;
  • разрешать повторное принятие токена без блокировки строки и уникального индекса;
  • сбрасывать только клиентский кеш, оставляя неверную связь в базе;
  • переносить все данные пользователя между компаниями без аудита владельцев;
  • считать email-домен достаточным доказательством принадлежности к организации.

Как предотвратить повторение проблемы

Логируйте жизненный цикл приглашения отдельными событиями: created, sent, opened, accepted, revoked и expired. В событии храните invitation id, organization id, actor id, user id и correlation id, но не исходный токен. Добавьте метрику попыток принятия приглашения в организацию, отличную от active tenant текущей сессии: это не всегда ошибка, но полезный сигнал для проверки.

В код-ревью закрепите правило: tenant для операции определяется сервером из авторизованного membership или доверенной доменной сущности. Любой organization_id от клиента считается только запрошенным контекстом и проходит отдельную проверку доступа.

Когда нужна помощь с multi-tenant-логикой

Если приглашения связывают сотрудников с чужими компаниями, сначала ограничьте спорный доступ и сохраните журналы. Я могу проверить модель users, memberships и invitations, найти место подмены tenant, исправить транзакцию принятия, SSO и кеш, а затем добавить тесты изоляции между организациями. Для оценки пришлите схему таблиц, один обезличенный пример приглашения и последовательность запросов без паролей и действующих токенов.