Приглашение с ограниченным сроком действия
Чтобы Вера стала редактором «Севера», Анна должна предложить ей участие. Простое добавление любого subject по тексту формы неудобно: владелец может не знать идентификатор внешней системы входа, а ошибочный адрес даст доступ другому человеку. Приглашение разделяет предложение и подтверждённое принятие.
В «Пространстве» приглашение содержит компанию, нормализованный адрес, предлагаемую роль, срок и случайный токен. Учебный API возвращает токен владельцу для ручного сценария; письма не отправляются. В настоящем продукте ссылка доставлялась бы адресату через отдельный процесс. Полный код расположен в InvitationEndpoints.cs архива, выполнение операций — not_run.
Токен подтверждает предложение, а не личность
Случайный токен нужен, чтобы нельзя было принять произвольное приглашение по последовательному номеру. Но обладатель ссылки ещё не обязательно Вера. Ссылка может быть переслана или попасть в историю браузера. Поэтому принятие требует входа, а адрес текущей личности должен совпасть с адресом приглашения.
В нашем локальном адаптере email берётся из таблицы users по subject. Заголовок X-Dev-Email не участвует в проверке. Настоящий механизм входа должен предоставлять подтверждённый адрес либо другую согласованную идентичность. Самостоятельно объявленный email в теле принятия не является подтверждением владения адресом.
Токен создаётся из криптографически случайных байтов. В базе сохраняется его SHA-256, а исходное значение возвращается только при выдаче. Ниже — завершённый фрагмент генерации из обработчика; SQL затем сохраняет tokenHash, но не token.
var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
var tokenHash = Convert.ToHexString(
SHA256.HashData(Encoding.UTF8.GetBytes(token)));
var expiresAt = DateTimeOffset.UtcNow.AddDays(2);
Хеширование снижает последствия чтения таблицы приглашений: готовую ссылку из значения хеша не восстановить прямым копированием. Оно не исправляет короткий предсказуемый токен, поэтому случайность нужна отдельно. Соль пароля здесь не решает главную задачу: исходное значение уже имеет большой случайный запас, а поиск выполняется по точному хешу.
Владелец предлагает ограниченную роль
Анна может пригласить Веру как editor либо viewer. Создавать owner через приглашение наш пример не позволяет: владение передаётся уже известному участнику отдельным действием. Это уменьшает риск случайно выдать ключевые полномочия по опечатке адреса. Администратор имеет те же разрешённые роли приглашения и не обходит матрицу предыдущего урока.
Повторное приглашение того же адреса не должно бесконтрольно создавать десятки действующих ссылок. В схеме есть уникальность активного предложения для пары компания и email. Поскольку срок сам по себе не удаляет строку из частичного индекса, истёкшие приглашения явно переводятся в expired перед новой выдачей. Нельзя использовать текущее время как динамическое условие обычного уникального индекса.
При выдаче проверяется, не является ли адрес уже участником этой компании. Если является, API возвращает 409 и предлагает менять роль существующего членства. Такое поведение сохраняет смысл приглашения: оно добавляет новую связь, а не скрыто заменяет старую роль. Иначе admin мог бы повысить участника через маршрут, правила которого отличаются от управления ролями.
Срок проверяется серверными часами в базе или общей временной шкалой приложения. Часы браузера не определяют допустимость ссылки. Отображаемая дата может быть местной, но сравнение с expires_at должно означать один момент. В архиве срок хранится как timestamptz и возвращается в формате ISO.
Принятие фиксирует два изменения вместе
Маршрут принятия находится вне выбранной группы организации: Вера ещё не имеет членства и потому не прошла бы обычный tenant filter. Однако он требует входа, находит приглашение по хешу и сам строит область из сохранённой записи. Компания здесь не приходит из тела клиента.
Полный SQL поиска блокирует строку предложения, чтобы два одинаковых запроса не приняли её независимо. Далее в той же транзакции проверяются срок, состояние компании и адрес пользователя, добавляется членство и меняется состояние приглашения.
SELECT organization_id,id,normalized_email,role,expires_at,status
FROM invitations
WHERE token_hash=@hash
FOR UPDATE;
В окончательном проекте поиск использует отдельный реестр хешей и затем открывает ограниченный сеанс нужной компании. Такой реестр содержит маршрут к предложению, но не список проектов. Это техническая таблица для входящей ссылки; прямой общий SELECT прикладных данных ради нахождения компании не требуется.
Ожидаемый первый ответ для Веры — успешное принятие и новое членство editor в «Севере». Повтор с той же личностью возвращает уже завершённый результат, а повтор другой личности — отказ. Идемпотентный ответ не означает повторное создание членства: первичный ключ и заблокированное состояние исключают вторую связь.
Если вставка членства удалась, а обновление приглашения не сохранилось, общая транзакция откатывает оба действия. Это важно при сбое: иначе ссылка останется действующей, хотя участие уже создано. Так же записывается аудит принятия, чтобы история не расходилась с составом компании.
Просроченная и пересланная ссылка
Попробуйте в ручном сценарии принять предложение Веры от Бориса. Ожидается отказ из-за несовпадающего подтверждённого адреса. Затем дождитесь срока либо измените учебную запись административным SQL и повторите принятие Верой: ожидается 410 для истёкшего предложения. Курс описывает эти варианты как ожидаемые, а не как выполненные запросы.
Не выдавайте в ответе на неверный токен email приглашённого человека. Неправильная ссылка не даёт права узнавать состав компании. Для действующей ссылки после входа можно показать название компании и предлагаемую роль, но правило раскрытия нужно сохранить одинаковым в просмотре и принятии.
При отзыве предложения состояние становится revoked; старый токен перестаёт давать участие. Повторная выдача создаёт новое случайное значение. Менять срок у старой ссылки без ведома владельца опасно, потому что она могла уже попасть не тому адресату. История приглашений остаётся предметным аудитом, а исходные токены не пишутся в журнал.
Адрес не является поиском участника
Нормализация email в примере ограничена Trim и приведением к нижнему регистру. Она соответствует заранее заданным fixture-адресам, а не универсальному правилу всех систем личности. При настоящем входе используйте согласованный нормализованный идентификатор провайдера, учитывая его подтверждение. Отображаемое имя пользователя не подходит для сопоставления: два участника могут называться одинаково, а один человек вправе сменить имя без потери членства.
Следующий урок вводит согласованное изменение версии проекта. Мы уже применили блокировки к одноразовому предложению; теперь увидим, как предотвратить потерю изменения, когда два участника редактируют один обычный документ.