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

Выборки только внутри своей организации

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

Рассмотрим список и получение проекта по идентификатору на данных «Севера» и «Юга». В обоих есть «План сезона», поэтому фильтрация по названию не определяет компанию. Мы будем использовать TenantContext.OrganizationId, проверенный в предыдущем уроке. Полный код WorkspaceEndpoints.cs находится в архиве; приведённые ответы ожидаемые, выполнение проекта — not_run.

Условие области перед условием поиска

Ниже показан полный SQL списка из обработчика. Параметр @organization поступает из доверенного контекста, @limit ограничивает объём ответа. Параметризация защищает структуру запроса от данных клиента, но сама по себе не создаёт изоляцию: принадлежность выражена отдельным условием.

SELECT id, name, version
FROM projects
WHERE organization_id = @organization
  AND deleted_at IS NULL
ORDER BY id
LIMIT @limit;

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

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

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

Поиск одной строки сохраняет компанию

Опасный вариант выглядит как WHERE id = @id. Он кажется безопасным, потому что UUID трудно угадать. Но идентификатор может оказаться в истории браузера, письме, журнале либо ссылке другого пользователя. Секретность идентификатора не должна быть единственным условием доступа к данным.

В завершённом примере поиск использует оба значения ключа. Следующий листинг — полный SQL получения одного проекта, а не программа с отсутствующими определениями.

SELECT id, name, version
FROM projects
WHERE organization_id = @organization
  AND id = @id
  AND deleted_at IS NULL;

Пусть Анна выбрала «Юг» и передала UUID проекта «Севера». Ожидаемый результат — 404, хотя Анна имеет доступ к «Северу» в другом запросе. Контекст текущего запроса ограничен «Югом». Автоматически переключать компанию по владельцу найденного объекта нельзя: интерфейс мог бы выполнить действие в неожиданной области, а часть связанных запросов осталась бы в прежней.

Такое правило удобно и для клиента. Ответ 404 означает, что по выбранному адресу доступной строки нет. Клиент не обязан выяснять, был ли неверным идентификатор проекта, выбор компании или ссылка из старого окна. Если продукт хочет предложить переключение компании, это отдельная явно разрешённая операция со своей проверкой членства.

Счётчик может раскрыть чужие сведения

Изоляция относится не только к полным документам. Значение «всего проектов: 57» уже сообщает что-то о деятельности компании. Поэтому счётчик использует то же условие области и ту же видимость удалённых строк. Иначе список окажется пустым, а число проектов будет учитывать соседей.

Для сравнения мысленно добавьте «Югу» десять проектов. Счётчик «Севера» должен остаться прежним. Если он вырос, проблема находится в запросе агрегирования. Не пытайтесь исправить её скрытием числа в интерфейсе: другой клиент сможет вызвать тот же API, а позднее неверное число попадёт в отчёт или ограничение тарифа.

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

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

Диагностика пустого ответа

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

В журнале полезно записать компанию и имя операции. Не записывайте туда полный текст каждого проекта: это создаёт новую копию клиентских данных и усложняет их жизненный цикл. Если журнал нужен для объяснения отказа доступа, ему достаточно факта отсутствующего членства или роли, а не содержимого чужого объекта.

При ручном изучении поменяйте только UUID организации в адресе Анны. Ожидаемо содержимое списка изменится, а данные исходной компании останутся в базе. Затем поменяйте только UUID проекта, сохранив область. Так вы отдельно наблюдаете две причины отбора: принадлежность и идентичность объекта. Замена обоих параметров одновременно скрывает, какое условие определило результат.

Полный пример открывает чтение в транзакции и повторно проверяет актуальное членство. После добавления RLS эта же транзакция получит локальный параметр PostgreSQL. Поэтому обработчикам не понадобится новый способ узнавать компанию. Устойчивый контракт доступа полезнее, чем несколько независимых «защитных» заголовков в каждом методе.

Следующий урок добавит создание и изменение проекта. В записи принадлежность становится ещё строже: клиент не выбирает владельца через тело, а условие изменения должно ограничивать затронутые строки тем же контекстом, который вы только что применили к чтению.

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