Перейти к содержанию

Доверенный контекст выбранной компании

Пользователь может подставить в адрес UUID любой компании. Поэтому обработчик не должен начинать чтение проектов сразу после разбора маршрута. Сначала сервер подтверждает личность и находит её членство в указанной организации. Полученное сочетание будем называть доверенным контекстом компании: его создал сервер на основании своих данных.

В этом уроке связываются три части «Пространства»: локальный вход, поиск членства и общий фильтр группы /orgs/{organizationId}. Полные определения находятся в Authentication.cs, TenantContext.cs и Program.cs архива. Листинги ниже показывают отдельные завершённые типы либо обозначенные фрагменты. Работа API при подготовке не проверялась; ответы приведены как ожидаемые.

Личность приходит из механизма входа

В реальном приложении аутентификация проверяет предъявленные credentials и создаёт ClaimsPrincipal. Наш локальный адаптер читает X-Dev-User, но разрешает только anna, boris и vera из исходных записей. Сам по себе такой заголовок не защищает вход. Он нужен для воспроизводимого изучения ролей без настройки внешнего сервиса личности.

Полный проект отказывается стартовать вне Development и слушает 127.0.0.1. Эти ограничения делают область применения адаптера явной. Они не превращают заголовок в промышленный механизм входа: доступный извне reverse proxy либо перенос этого кода в другой сервер потребовали бы замены адаптера. Иначе любой клиент мог бы объявить себя Анной.

В ClaimsPrincipal сохраняется только идентификатор личности. Роль компании не берётся из заголовка, и UUID компании не становится claim автоматически. Документация ASP.NET Core о безопасности Minimal APIs разделяет установление личности и проверку разрешений. В нашем примере это разделение помогает не доверять переданной клиентом роли.

Контекст заполняется один раз на запрос

Ниже приведено полное определение контейнера TenantContext. Он создаётся как scoped-сервис и не разделяется двумя HTTP-запросами. Тип хранит только проверенные сведения; тело запроса не может напрямую изменить эти свойства.

public sealed class TenantContext
{
    public Guid OrganizationId { get; private set; }
    public string Subject { get; private set; } = "";
    public string Role { get; private set; } = "";
    public bool IsResolved { get; private set; }

    public void Resolve(Guid organizationId, string subject, string role)
    {
        if (IsResolved) throw new InvalidOperationException("Tenant already resolved");
        OrganizationId = organizationId;
        Subject = subject;
        Role = role;
        IsResolved = true;
    }
}

Запрет повторного заполнения предотвращает скрытую смену организации внутри одного запроса. Если вложенный сервис неожиданно захочет обработать другую компанию, он должен получить отдельный явный контекст системной операции. Переиспользовать уже заполненный HTTP-контекст опасно: часть запросов могла успеть выполниться для прежнего владельца.

Scoped-время жизни также решает проблему переключения компании в интерфейсе. Два открытых окна Анны могут одновременно работать с «Севером» и «Югом». Сервер не сохраняет «последнюю выбранную компанию» в общем поле пользователя и не заставляет эти окна менять друг другу область. Каждый запрос содержит своё предложение компании и проходит собственную проверку.

Общий фильтр группы маршрутов

Группа маршрутов требует аутентификации, а endpoint filter получает UUID из route values. Затем он выполняет поиск организации и членства, заполняет scoped-контекст и только после этого вызывает обработчик. Возможность добавления фильтра к группе описана в документации Minimal API filters.

Следующий SQL — полный запрос разрешения области из TenantResolver. Значения передаются параметрами, а не вставляются в строку SQL. @subject получен из проверенного principal, @organization — из маршрута.

SELECT m.role
FROM memberships AS m
JOIN organizations AS o ON o.id = m.organization_id
WHERE m.organization_id = @organization
  AND m.subject = @subject
  AND o.status <> 'deleted';

Если строка отсутствует, фильтр возвращает 404 и не запускает метод списка проектов. Это важнее последующей обработки исключения: данные соседней компании вообще не должны быть загружены в память ради выяснения, можно ли их показать. Организация с состоянием deleted также не становится доступной через старое членство.

Состояние read_only здесь не запрещает вход. Участник такой компании ещё вправе просмотреть данные и получить сведения о состоянии. Запрет изменений будет применён при открытии пишущей транзакции. Если запретить вход целиком, владелец не сможет увидеть объяснение ограничения или запустить разрешённую процедуру выгрузки.

Отзыв членства между двумя операциями

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

В завершённом примере эта повторная проверка берёт строку членства с FOR SHARE. Удаление членства должно дождаться окончания операции, а следующая операция уже получит отказ. Так можно объяснить точный смысл отзыва доступа: успешно начавшееся действие заканчивается согласованно, будущие действия не начинают обработку. Курс не обещает мгновенно отменить уже отправленный ответ.

Кэш членства добавляет ещё одну границу. Если хранить роль в кэше десять минут, удалённый участник потенциально сохранит право на этот срок. В нашем небольшом примере кэш не используется. Когда он появится, понадобится явное решение о допустимой задержке, версии членства и очистке кэша; ускорение одного SQL-запроса не должно молча менять контракт безопасности.

Для наблюдения выполните четыре запросa из первого урока. Ожидаемо Анна получит обе свои области, Борис — только «Юг», а Вера пока ни одну. Затем подставьте заголовок X-Organization-Role: owner: ничего измениться не должно, поскольку фильтр не читает такую роль. Это полезный способ отличить серверную проверку от доверия к данным клиента.

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

Если адрес выбран для «Юга», а тело содержит северный UUID, метод не делает повторного разрешения области по этому полю. Такой повтор мог бы смешать компании внутри одного запроса. При необходимости обработать две компании одновременно создавайте две отдельные операции с независимой проверкой прав. Даже владелец обеих компаний должен явно понимать, какой результат относится к какой области. Контекст удобно включать в предметный журнал операции, но нельзя сохранять там исходный заголовок входа как долговечное доказательство личности. После замены локального адаптера на настоящий механизм входа проверка членства продолжает использовать серверный principal. Этот переход меняет только источник subject и оставляет доверенный контекст, SQL-условия и составные связи прежними.

После разрешения контекста обработчик получает организацию из TenantContext, а не повторно из тела. Следующий урок строит на этом изолированные выборки проектов. Один проверенный контекст будет сопровождать список, поиск конкретного проекта и счётчик, чтобы разные формы чтения не создавали разные правила принадлежности.

Оглавление серии