Создание и изменение без смены владельца
После чтения проектов естественно добавить создание и редактирование. Здесь появляются два новых риска: клиент может передать чужую организацию в теле, а обработчик может обновить чужой проект по одному UUID. Исправление должно находиться в операции записи, потому что скрытая кнопка интерфейса не ограничивает HTTP-клиента.
В «Пространстве» тело создания содержит только имя. Принадлежность назначает сервер из TenantContext. Для изменения используются организация, идентификатор проекта и ожидаемая версия. Последнее поле подробно разберём позже; сейчас оно уже входит в контракт, чтобы не менять форму запросов посреди курса. Полный пример не запускался, поэтому результат операций ниже является ожидаемым.
Тело описывает новое содержимое
Ниже приведены целые определения DTO из Contracts.cs. В них нет OrganizationId, роли автора и отметки удаления. Эти значения не относятся к разрешённому выбору клиента при создании проекта.
public sealed record CreateProjectRequest(string Name);
public sealed record RenameProjectRequest(string Name, long ExpectedVersion);
public sealed record DeleteProjectRequest(long ExpectedVersion);
Если клиент отправит дополнительное поле organizationId, оно не станет частью DTO и не будет использовано SQL. Однако полезно различать отсутствие такого свойства в модели и гарантированный отказ на неизвестное JSON-поле: стандартная десериализация может просто проигнорировать лишнее. Наше доказательство безопасности заключается в том, что принадлежность вообще не читается из тела.
Имя нормализуется через Trim, затем проверяется длина от одного до ста шестидесяти символов. Проверка помогает сообщить 400 до обращения к базе. SQL-ограничение остаётся второй защитой. Не применяйте Trim к содержимому, где начальные пробелы являются частью предметных данных; здесь правило выбрано для короткого имени проекта.
Проверка роли происходит после определения компании. Владелец «Севера» может создать проект в «Севере», но не получает такое право в «Юге» автоматически. Для будущей роли editor создадим право записи без права управления составом. Код не должен определять разрешение через выражение «пользователь хоть где-то администратор».
Организация берётся из серверного контекста
Создание использует новый UUID, доверенную организацию и проверенное имя. Следующий листинг — полный SQL этой операции. @id создаёт сервер, а @organization приходит из контекста транзакции.
INSERT INTO projects(organization_id,id,name)
VALUES (@organization,@id,@name)
RETURNING id,name,version;
Ожидаемый ответ 201 содержит версию 1. Заголовок Location указывает на новый проект в той же организации. Это не декоративная ссылка: она закрепляет область следующего чтения. Если Location забывает компанию, клиент вынужден самостоятельно догадаться, в какую область поместить идентификатор.
Членство и состояние организации повторно проверяются внутри транзакции. Компания со статусом read_only позволяет чтение, но создание должно получить 409 с предметным кодом organization_read_only. Возврат обычного 500 ошибочно обозначал бы ожидаемое состояние как сбой сервера. При статусе deleting новые прикладные изменения также прекращаются.
Такой порядок нужен и для гонки с изменением состояния. Если владелец переводит компанию в режим удаления, параллельный запрос не должен пройти проверку до этого изменения, а записать проект после него без согласования. Блокировка строки организации в общей транзакции задаёт понятную границу: начатая операция завершится прежде смены состояния либо увидит новое состояние и откажет.
Условие UPDATE ограничивает затронутые строки
Для переименования недостаточно ранее прочитать проект и убедиться, что он свой. Между чтением и обновлением может измениться версия. Само изменение должно повторять все условия, от которых зависит разрешение операции. Тогда база определяет успех одним атомарным действием.
UPDATE projects
SET name=@name, version=version+1
WHERE organization_id=@organization
AND id=@id
AND version=@expected_version
AND deleted_at IS NULL
RETURNING id,name,version;
Если строка возвращена, ожидаемый эффект — новое имя и версия, увеличенная на единицу. Если строки нет, обработчик делает ограниченное чтение в той же компании: отсутствие проекта даёт 404, несовпадение версии — 409. Это позволяет клиенту предложить перечитать документ и не выдаёт сведения о соседней компании.
Ошибка UPDATE projects SET ... WHERE id=@id способна изменить чужую строку, даже если правильный список никогда её не показывает. Поэтому аудит изоляции должен охватывать запись независимо от чтения. Составной ключ поможет выразить адрес объекта, но SQL всё равно может забыть часть ключа. В одиннадцатом уроке добавим RLS, чтобы такая ошибка не стала единственной линией защиты.
Мягкое удаление использует похожий UPDATE: записывает deleted_at, увеличивает версию и проверяет компанию, UUID и ожидаемую версию. Оно не перемещает проект в общую таблицу «удалённые». Перемещение создало бы ещё одну таблицу, где легко потерять принадлежность. Сохранение строки в своей области упрощает аудит и исключает внезапную доступность старой ссылки.
Транзакция связывает результат и событие
После изменения проекта запишем аудит в той же транзакции. Если сохранение проекта прошло, а запись аудита завершилась ошибкой, транзакция откатывает оба действия. Иначе журнал обещал бы полную историю, но пропускал бы отдельные изменения при временных сбоях. Подробный формат аудита появится в четырнадцатом уроке.
Не отправляйте внешние уведомления до фиксации транзакции. Письмо «проект создан» может уйти, после чего INSERT откатится. Для таких действий нужна отдельная очередь либо outbox. Наш учебный обработчик возвращает ответ после commit, а фоновые задачи добавляются только в пределах той же области. Пока внешних писем в проекте вообще нет.
При ручном рассмотрении выполните создание от Анны в «Севере», а затем такой же запрос от Анны в «Юге». Ожидаемо первый получит 201, второй — 403, потому что viewer не пишет. Затем используйте UUID северного проекта в адресе «Юга»: результат должен остаться 404, даже если UUID корректен и существует в базе.
Удаление использует явную версию
В полном API DELETE принимает expectedVersion в query string, поэтому его можно вызвать без неявного тела для этого HTTP-метода. Определение DeleteProjectRequest показывает альтернативный DTO, но не подключено к маршруту. Если вы выберете тело, обозначьте привязку явно и согласуйте её с клиентом. Форма передачи не меняет обязательные условия компании, объекта и версии. После успешного мягкого удаления старый GET возвращает отсутствие, а аудит сохраняет факт завершённого действия.
Теперь принадлежность задана и при чтении, и при записи. Но задача ещё может ссылаться на проект другой организации, если связь содержит только UUID. Следующий урок вводит составные ограничения для связанных объектов, которые сохраняют границу независимо от того, какой обработчик выполняет INSERT.