diff --git a/editorial/agent-rewrites/011.json b/editorial/agent-rewrites/011.json index 394833d..d8f74c6 100644 --- a/editorial/agent-rewrites/011.json +++ b/editorial/agent-rewrites/011.json @@ -3,5 +3,5 @@ "slug": "editorial-2027-09-mechanism-mentor-series", "title": "JSON Schema не разрешает операцию: как разделить форму, инвариант и состояние", "excerpt": "Валидный JSON может описывать недопустимое действие. Разбираем границу между JSON Schema, проверкой связи полей и конфликтом текущего состояния ресурса.", - "contentHtml": "

Сервис принимает запрос на изменение заказа. JSON проходит проверку схемы: поля есть, типы верны, сумма положительная. Через несколько миллисекунд сервис отвечает отказом, потому что заказ уже оплатили или лимит клиента изменился. В логах оба случая часто выглядят одинаково: validation failed. Клиент повторяет запрос, оператор видит лишнюю операцию, а разработчик ищет ошибку то в схеме, то в базе. Цена ошибки — потерянное время и риск повторить действие, которое нельзя повторять.

\n

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

\n

Три вопроса вместо одного «valid»

\n

Первый вопрос относится к форме. Запрос должен быть объектом, limit — целым числом от 1 до 100, а state — одним из заранее объявленных значений. Проверка не читает базу и не вызывает сеть. Для одинакового входа она всегда возвращает одинаковый ответ.

\n

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

\n

Третий вопрос относится к состоянию. Документ может быть безупречным, но ресурс уже изменился. Версия revision: 4 устарела, купон исчерпан, а роль пользователя не позволяет перевести заказ в новый статус. Такой отказ нельзя исправить изменением JSON-типа. Клиенту нужно перечитать ресурс, показать конфликт или прекратить операцию.

\n
Слой проверки и его граница
СлойЧто проверяемПример отказаГде выполнять
ФормаТип, обязательность, диапазон, enumlimit не является integerJSON Schema или валидатор на входе
ИнвариантСвязь нескольких полейfrom позже toЧистая доменная функция
СостояниеАктуальность и доступность ресурсаРевизия уже измениласьСервис, репозиторий, транзакция
ПравоРазрешение на операциюРоль не может отменить заказАвторизация до изменения состояния
\n

Что именно описывает JSON Schema

\n

Схема полезна там, где нужно зафиксировать форму документа и дать одинаковую проверку нескольким потребителям. Она задаёт типы, обязательные ключи, диапазоны, шаблоны, перечисления и структуру вложенных объектов. Она также делает контракт читаемым для инструментов. OpenAPI 3.1 использует модель Schema Object, совместимую с JSON Schema 2020-12 с оговорёнными изменениями, поэтому форму HTTP-запроса удобно держать рядом с описанием endpoint.

\n

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

\n

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

\n
\"Матрица
Один запрос проходит несколько независимых границ. У каждой границы свой источник данных, класс ошибки и следующий шаг клиента.
\n

Учебный пример: сначала форма, потом доменное правило

\n

Ниже — самостоятельный учебный фрагмент без HTTP-сервера и базы данных. Он показывает порядок вычислений, а не готовый production-код. Первая функция проверяет форму фильтра. Вторая проверяет связь полей. Обе функции чистые: их результат зависит только от переданного объекта.

\n
const filterSchema = {\n  type: 'object',\n  additionalProperties: false,\n  properties: {\n    limit: { type: 'integer', minimum: 1, maximum: 100 },\n    from: { type: 'string', format: 'date' },\n    to: { type: 'string', format: 'date' }\n  }\n};\n\nfunction validateRange(input) {\n  if (input.from && input.to && input.from > input.to) {\n    return { ok: false, reason: 'from-after-to' };\n  }\n  return { ok: true };\n}\n\nconst shapeIsValid = validateWithSchema(filterSchema, {\n  limit: 25, from: '2027-09-10', to: '2027-09-12'\n});\nconst rangeIsValid = validateRange({\n  from: '2027-09-12', to: '2027-09-10'\n});
\n

В примере validateWithSchema обозначает вызов выбранной библиотекой JSON Schema. Это намеренное сокращение: конкретные API библиотек различаются, а задача фрагмента — показать две границы. Первый вызов отвечает за форму. Второй не пытается читать ресурс и не решает, есть ли право на поиск. В учебных данных нет утверждения о производительности или поведении в production.

\n

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

\n
async function updateOrder(command, actor) {\n  const shape = validateCommandShape(command);\n  if (!shape.ok) return httpError(400, shape.reason);\n\n  const domain = validateCommandInvariant(command);\n  if (!domain.ok) return httpError(422, domain.reason);\n\n  if (!canEditOrder(actor, command.orderId)) {\n    return httpError(403, 'forbidden');\n  }\n\n  return db.transaction(async (tx) => {\n    const order = await tx.orders.getForUpdate(command.orderId);\n    if (!order || order.revision !== command.expectedRevision) {\n      return httpError(409, 'state-conflict');\n    }\n    return tx.orders.update(command.orderId, command.patch);\n  });\n}
\n

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

\n

Симптом → причина → проверка → действие

\n
Диагностика смешанной валидации
СимптомПричинаПроверкаДействие
Валидатор принимает ключ, а клиент его не используетСхема открыта или контракт не обновилиСверить unknown-key policy и чтение поляЗакрыть объект либо явно документировать расширение
Правильный JSON получает 400Ошибка состояния скрыта под ошибкой формыРазделить логи shape, invariant и stateВернуть отдельный класс ошибки и исправить retry
Повторный запрос иногда меняет уже изменённый ресурсНет идемпотентности или проверки ревизииПовторить команду при конкурентном обновленииДобавить idempotency key или условие версии
Тест схемы проходит, endpoint падаетСхема не покрывает runtime-веткуВызвать реальный handler с теми же даннымиДобавить интеграционный тест на границе
Клиент бесконечно повторяет запросКонфликт обозначен как временная ошибкаПроверить статус и тело ответаРазличить retryable отказ и конфликт ресурса
\n

Порядок внедрения

\n
  1. Назовите endpoint, метод, формат запроса и ожидаемый ответ. Не начинайте с общей функции validate: сначала определите документ.
  2. Выпишите поля, типы, обязательность, enum, диапазоны и политику неизвестных ключей. Сохраните положительный и отрицательный пример.
  3. Отделите правила, которые используют только вход, от правил, которым нужны два поля, часы, права или данные ресурса.
  4. Назначьте класс отказа. Неверная форма, нарушенный инвариант, запрет и конфликт состояния не должны превращаться в один boolean.
  5. Проверьте отрицательный путь: устаревшая ревизия, повтор команды, отсутствие ресурса, запрещённая роль и сетевой отказ должны приводить к ожидаемому действию клиента.
  6. Запустите проверку на реальном handler и на выбранной библиотеке схем. Unit-тест чистой функции не заменяет интеграционный тест.
  7. Опишите безопасное расширение. Если добавляется поле или значение enum, проверьте старого потребителя и решите, нужен ли период совместимости.
  8. Зафиксируйте наблюдаемый критерий готовности: каждый класс отказа имеет тест, статус, причину и понятный следующий шаг.
\n

Ограничения механизма

\n

Разделение слоёв не устраняет сложность домена. Схема может стать слишком строгой. Чистая функция может повторить правило, которое уже проверяет база. Транзакция может быть недоступна для внешнего сервиса. Авторизация может зависеть от времени и нескольких систем. В таких случаях нужно явно описать границу и риск, а не расширять JSON Schema до роли универсального движка правил.

\n

Статус HTTP тоже не заменяет доменный контракт. 409 Conflict подходит для конфликта с текущим состоянием ресурса, но тело ответа должно объяснить, что проверил сервис и что может сделать клиент. 422 может обозначать семантически неприемлемый документ, если это решение согласовано в API. Важна не магическая цифра, а стабильное различие между исправлением входа и разрешением конфликта.

\n

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

\n

Критерий готовности

\n

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

\n

Проверяемые источники

" + "contentHtml": "

Проблема начинается с запроса на изменение заказа. JSON проходит проверку: поля есть, типы верны, сумма положительная. Через несколько миллисекунд сервис отвечает отказом, потому что заказ уже оплатили или лимит клиента изменился. В логах оба случая часто выглядят одинаково: validation failed. Клиент повторяет запрос, оператор видит лишнюю операцию, а разработчик ищет ошибку то в схеме, то в базе. Цена ошибки — потерянное время и риск повторить действие, которое нельзя повторять.

\n

Причина в том, что слово «валидация» объединяет разные вопросы. JSON Schema отвечает, соответствует ли экземпляр описанной форме. Доменная проверка отвечает, согласованы ли поля между собой. Сервис проверяет текущий ресурс, полномочия и возможность применить команду. Тезис статьи простой: границу нужно проводить по источнику контекста. Пока проверке нужен только сам документ, её можно выполнить на входе. Как только нужны база, часы, права или другой запрос, это уже правило операции с отдельным результатом и отдельным тестом.

\n

Три вопроса вместо одного valid

\n

Начните с вопроса о форме. Запрос должен быть объектом, limit — целым числом от 1 до 100, а state — одним из заранее объявленных значений. Проверка не читает базу и не вызывает сеть. Для одинакового входа и одинакового выбранного диалекта она должна давать одинаковый результат.

\n

Затем проверьте связь полей. Диапазон дат требует, чтобы from не был позже to; сумма платежа должна соответствовать разрешённому типу операции; поле currency должно быть совместимо с суммой. Оба значения могут иметь правильный тип и формат, но вместе быть бессмысленными. Часть таких условий выражается в JSON Schema через композицию и условные конструкции, если их поддерживает выбранный диалект и валидатор. Чистая функция часто читается лучше. Место проверки вторично по сравнению с тем, что правило названо и тестируется отдельно.

\n

Наконец, проверьте применимость к состоянию. Документ может быть безупречным, но ресурс уже изменился: revision: 4 устарела, купон исчерпан или заказ перешёл в необратимый статус. Это не ошибка типа JSON. Клиенту нужно перечитать ресурс, показать конфликт или завершить сценарий без повторной отправки.

\n
Слой проверки, источник контекста и следующий шаг
СлойЧто проверяемПример отказаГде выполнять
ФормаТип, обязательность, диапазон, enumlimit не integerJSON Schema или валидатор на входе
ИнвариантСвязь нескольких полейfrom позже toЧистая доменная функция
СостояниеАктуальность и доступность ресурсаРевизия уже измениласьРепозиторий внутри согласованной операции
ПравоРазрешение на командуРоль не может отменить заказПолитика авторизации до изменения состояния
\n

Что именно описывает JSON Schema

\n

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

\n

Слово «формат» требует осторожности. В JSON Schema Draft 2020-12 vocabulary format разделён на аннотацию и assertion: реализация может собирать информацию о формате, но не обязана отклонять экземпляр только из-за неё, если не включён режим проверки форматов. Поэтому для дат, URI и email нужно зафиксировать диалект, библиотеку и настройки. Если дата важна для домена, дополните схему явным правилом и тестом, а не рассчитывайте на одинаковое поведение всех валидаторов.

\n

Неизвестные поля требуют отдельного решения. Закрытый объект с additionalProperties: false ловит опечатку, но усложняет расширение контракта. Открытый объект легче развивать, но ошибочный ключ может быть молча проигнорирован потребителем. Выберите политику для конкретного endpoint, внесите её в схему и проверьте отрицательным примером. В OpenAPI 3.1 Schema Object основан на JSON Schema Draft 2020-12, но является его надмножеством с собственным диалектом; не переносите предположения о поведении одной реализации в другую.

\n
\"Матрица
Один документ проходит несколько границ. У каждой границы свой источник данных, класс ошибки и следующий шаг клиента.
\n

Самодостаточная проверка формы и инварианта

\n

Ниже — запускаемый учебный фрагмент без HTTP-сервера, базы и внешних пакетов. Объект filterSchema показывает намерение контракта, а небольшая функция имитирует только нужный для примера набор проверок. Это не реализация JSON Schema и не замена библиотеке: цель фрагмента — сделать видимым порядок «форма → инвариант» и дать отрицательные случаи, которые можно воспроизвести командой node validation-example.mjs.

\n
import assert from 'node:assert/strict';\n\nconst filterSchema = {\n  type: 'object',\n  additionalProperties: false,\n  properties: {\n    limit: { type: 'integer', minimum: 1, maximum: 100 },\n    from: { type: 'string', format: 'date' },\n    to: { type: 'string', format: 'date' }\n  }\n};\n\nconst isIsoDate = (value) => {\n  if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(value)) return false;\n  const parsed = new Date(`${value}T00:00:00Z`);\n  return parsed.toISOString().slice(0, 10) === value;\n};\n\nfunction validateShape(input) {\n  if (!input || typeof input !== 'object' || Array.isArray(input)) {\n    return { ok: false, reason: 'object-required' };\n  }\n  const allowed = new Set(Object.keys(filterSchema.properties));\n  if (Object.keys(input).some((key) => !allowed.has(key))) {\n    return { ok: false, reason: 'unknown-property' };\n  }\n  if ('limit' in input && (!Number.isInteger(input.limit)\n    || input.limit < 1 || input.limit > 100)) {\n    return { ok: false, reason: 'limit-out-of-range' };\n  }\n  for (const key of ['from', 'to']) {\n    if (key in input && !isIsoDate(input[key])) {\n      return { ok: false, reason: `${key}-must-be-date` };\n    }\n  }\n  return { ok: true };\n}\n\nfunction validateRange(input) {\n  if (input.from && input.to && input.from > input.to) {\n    return { ok: false, reason: 'from-after-to' };\n  }\n  return { ok: true };\n}\n\nassert.deepEqual(validateShape({\n  limit: 25, from: '2027-09-10', to: '2027-09-12'\n}), { ok: true });\nassert.equal(validateShape({ limit: 0 }).ok, false);\nassert.equal(validateShape({ typo: 25 }).reason, 'unknown-property');\nassert.equal(validateRange({\n  from: '2027-09-12', to: '2027-09-10'\n}).reason, 'from-after-to');\nconsole.log('shape and invariant checks passed');
\n

Здесь сравнение дат безопасно только потому, что isIsoDate сначала проверяет календарную дату и приводит её к единому формату. Для более сложных календарей, часовых поясов и локального времени правило нужно заменить доменной библиотекой и тестами. В настоящем сервисе структурную часть передайте выбранному JSON Schema-валидатору, явно включите нужную проверку format, а функцию диапазона оставьте чистой и вызывайте после успешной проверки формы.

\n

Состояние, права и гонка между чтением и записью

\n

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

\n

Практический вариант — передавать ожидаемую ревизию и делать условное обновление внутри транзакции: обновить запись только при совпадении идентификатора и версии, увеличить версию атомарно, а отсутствие обновлённой строки превратить в конфликт. Точный SQL, блокировки и уровень изоляции зависят от СУБД. Поэтому пример с getForUpdate нельзя копировать без проверки драйвера, а тест должен запускать два конкурентных изменения, а не только вызывать функцию два раза подряд.

\n

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

\n

Для конфликта текущего ресурса RFC 9110 определяет 409 Conflict и ожидает от сервера достаточно сведений, чтобы распознать источник конфликта. Для HTTP-precondition, заданного заголовками вроде If-Match, применяется отдельная семантика 412 Precondition Failed. Выбранный статус должен соответствовать реальному контракту API, а тело — содержать стабильный код причины и безопасное действие: перечитать ресурс, объединить изменения или прекратить повтор.

\n

Симптом → причина → проверка → действие

\n
Диагностика смешанной валидации
СимптомПричинаПроверкаДействие
Валидатор принимает ключ, а клиент его не используетСхема открыта или контракт не обновилиСверить политику unknown keys и чтение поляЗакрыть объект либо документировать расширение
Правильный JSON получает 400Ошибка состояния скрыта под ошибкой формыРазделить логи shape, invariant и stateВернуть отдельный класс ошибки и исправить retry
Повторный запрос меняет уже изменённый ресурсНет идемпотентности или проверки версииПовторить команду при конкурентном обновленииДобавить ключ идемпотентности или условие версии
Тест схемы проходит, endpoint падаетСхема не покрывает runtime-веткуВызвать реальный handler с теми же даннымиДобавить интеграционный тест на границе
Клиент бесконечно повторяет запросКонфликт обозначен как временная ошибкаПроверить статус, код причины и retry policyРазличить повторяемый отказ и конфликт ресурса
\n

Порядок внедрения

\n
  1. Назовите endpoint, метод, формат запроса и ожидаемые ответы. Не начинайте с общей функции validate: сначала определите документ и команду.
  2. Выпишите типы, обязательность, enum, диапазоны, политику неизвестных ключей и диалект схемы. Сохраните положительный и отрицательный экземпляры.
  3. Отделите правила, которым нужен только вход, от правил, которым нужны два поля, время, права или данные ресурса.
  4. Назначьте класс отказа. Неверная форма, нарушенный инвариант, запрет, конфликт и временная недоступность не должны превращаться в один boolean.
  5. Зафиксируйте поведение format в выбранном валидаторе. Один и тот же ключ может быть аннотацией в одной конфигурации и отклонением в другой.
  6. Проверьте отрицательные пути: устаревшую ревизию, повтор команды, отсутствие ресурса, запрещённую роль и сетевой отказ. Для каждого укажите, должен ли клиент повторять запрос.
  7. Запустите unit-тест чистой функции и интеграционный тест реального handler. Для конкурентного изменения создайте два запроса с одной ожидаемой ревизией и подтвердите, что успешен только допустимый.
  8. Опишите безопасное расширение. При добавлении поля или значения enum проверьте старого потребителя и решите, нужен ли период совместимости.
  9. Зафиксируйте критерий готовности: каждый класс отказа имеет тест, HTTP-статус, код причины, наблюдаемую запись и понятный следующий шаг клиента.
\n

Ограничения подхода

\n

Разделение слоёв не устраняет сложность домена. Схема может стать слишком строгой. Чистая функция может повторить правило, которое уже проверяет база. Транзакция может быть недоступна для внешнего сервиса. Авторизация может зависеть от нескольких систем и измениться между проверкой и записью. В таких случаях нужно описать границу согласованности и риск, а не расширять JSON Schema до роли универсального движка правил.

\n

HTTP-статус тоже не заменяет доменный контракт. 409 подходит для конфликта с текущим состоянием ресурса, но не обязан быть единственным выбором для каждого бизнес-отказа. 422 уместен, когда сервер понимает тип содержимого и не может обработать содержащиеся инструкции; конкретное применение согласуйте в API. Важна стабильная связь «причина → статус → действие», а не магическая цифра.

\n

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

\n

Критерий готовности

\n

Решение для выбранного endpoint готово, когда команда может показать четыре независимых теста: схема отклоняет неправильный тип, доменная функция отклоняет неверную связь полей, политика авторизации отклоняет запрещённого субъекта, а атомарное изменение отклоняет устаревшую ревизию. Для каждого теста указаны статус, причина и действие клиента. Положительный сценарий проходит через тот же порядок. Если на любой вопрос команда отвечает только «валидатор всё проверит», граница ещё не проведена.

\n

Проверяемые источники

" } diff --git a/editorial/agent-rewrites/012.json b/editorial/agent-rewrites/012.json index 0e14d02..b03e9e4 100644 --- a/editorial/agent-rewrites/012.json +++ b/editorial/agent-rewrites/012.json @@ -3,5 +3,5 @@ "slug": "editorial-2027-09-practice-mentor-series", "title": "Совместимый API-ответ: как поймать breaking change до релиза", "excerpt": "Разбираем ответ HTTP-метода на границе сервиса: какие изменения ломают старого клиента, как отделить форму JSON от состояния системы и чем доказать совместимость.", - "contentHtml": "

Сервис возвращает HTTP 200, но клиент падает при разборе ответа. В логах появляется ошибка чтения поля, неизвестное значение перечисления или попытка вызвать метод у числа вместо строки. Сервер считает запрос успешным, а потребитель — нет. Цена ошибки растёт быстро: приходится искать все версии клиента, откатывать серверный код и решать, не потеряны ли уже записи, созданные по новой схеме.

\\n

Причина обычно не в синтаксической ошибке JSON. Команда меняет форму ответа как внутреннюю модель и не замечает потребителей. Она удаляет поле, делает новое поле обязательным, меняет тип или добавляет значение в enum. Каждый такой diff имеет собственный риск. Тезис простой: API-ответ нужно проверять как контракт двух сторон — по форме, по поведению старого клиента и по условиям, которые схема не описывает.

\\n

Что именно обещает ответ

\\n

Контракт начинается с конкретной границы: метод, путь, статус, media type и тело. Для GET /customers/{id} можно зафиксировать объект с обязательными полями id, revision и state. У id строковый тип. У revision положительное целое число. У state закрытый набор значений active и blocked.

\\n

Эта форма отвечает на вопрос «можно ли разобрать JSON». Она не отвечает на вопросы «имеет ли пользователь право видеть клиента» и «не устарела ли ревизия записи». Эти проверки относятся к авторизации и состоянию. Если смешать их со схемой, ответ об ошибке станет неточным: клиент не поймёт, нужно ли исправить запрос, обновить данные или прекратить повторные попытки.

\\n
GET /customers/{id}\\nAccept: application/json\\n\\n200 OK\\nContent-Type: application/json\\n\\n{\\n  "id": "customer-17",\\n  "revision": 4,\\n  "state": "active"\\n}
\\n

Успешный ответ не становится совместимым только потому, что его принимает парсер JSON. Клиент может ветвить логику по state, строить URL из id и сравнивать revision с локальной версией. Сохранить синтаксис недостаточно: нужно сохранить значения и смысл, на которые опирается старый код.

\\n

Изменения, которые требуют решения

\\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Старый клиент получает ошибку при чтении поляПоле удалили или изменили его типСравнить старую и новую схему и найти чтения поляСохранить поле или выпустить новую версию
Клиент попадает в ветку «неизвестное состояние»Enum расширили без обработки нового значенияПрогнать старый switch на каждом значенииДобавить обработку либо не включать значение в старый контракт
Запросы начинают отклоняться после обновленияНовое поле объявили обязательнымОтправить старую форму без поляСделать поле необязательным или изменить версию
Клиент принимает ответ, но действует по неверной веткеСохранили тип, но изменили смысл значенияПроверить примеры поведения, а не только JSON SchemaСохранить семантику или переименовать поле
Ответ формально верен, но операция получает отказНарушено право или текущее состояние ресурсаПроверить авторизацию и условие версии отдельноВернуть точный 403/409 и не маскировать его под 400
\\n

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

\\n

Учебный валидатор на границе

\\n

Следующая функция показывает минимальную проверку ответа. Она не ходит в сеть и не читает базу. Входом служит уже разобранный JavaScript-объект. Функция принимает только известную форму и возвращает нормализованное значение. Это учебный пример: он показывает границу контракта, но не заменяет OpenAPI, JSON Schema, интеграционный тест или авторизацию.

\\n
function validateCustomerResponse(payload) {\\n  if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {\\n    return { ok: false, reason: 'body-must-be-object' };\\n  }\\n\\n  if (typeof payload.id !== 'string' || payload.id.length === 0) {\\n    return { ok: false, reason: 'id-must-be-non-empty-string' };\\n  }\\n\\n  if (!Number.isInteger(payload.revision) || payload.revision < 1) {\\n    return { ok: false, reason: 'revision-must-be-positive-integer' };\\n  }\\n\\n  if (!['active', 'blocked'].includes(payload.state)) {\\n    return { ok: false, reason: 'state-is-outside-enum' };\\n  }\\n\\n  return {\\n    ok: true,\\n    value: { id: payload.id, revision: payload.revision, state: payload.state },\\n  };\\n}\\n\\nconst accepted = validateCustomerResponse({\\n  id: 'customer-17', revision: 4, state: 'active',\\n});\\nconst rejected = validateCustomerResponse({\\n  id: 'customer-17', revision: 4, state: 'deleted',\\n});\\n\\nconsole.log(accepted.ok, accepted.value.state);\\nconsole.log(rejected.ok, rejected.reason);\\n// true active\\n// false state-is-outside-enum
\\n

Отдельный отрицательный пример важнее ещё одного успешного fixture. Если сервер начнёт отправлять state: deleted, валидатор обнаружит изменение до того, как клиент выполнит неверную ветку. Если сервер отправит revision: "4", отказ произойдёт по типу. Если поле исчезнет, причина должна назвать поле, а не скрыться за общим сообщением invalid response.

\\n
\"Схема
Граница совместимости состоит из трёх проверок: форма ответа, поведение потребителя и условия операции. Схема не доказывает наличие права доступа или актуальность данных.
\\n

Порядок проверки перед изменением

\\n
  1. Назовите endpoint, метод, статус и media type. Отделите тело ответа от заголовков, запроса и внутренней модели.
  2. Снимите текущую форму: обязательные поля, типы, nullable, enum и значения по умолчанию. Сохраните один успешный и несколько отрицательных примеров.
  3. Найдите потребителей. Проверьте чтение полей, ветвления по enum, строгие декодеры и преобразователи DTO. Один найденный клиент не доказывает, что найден каждый.
  4. Сравните старую и новую форму. Отдельно отметьте удаление поля, изменение типа, сужение enum и появление обязательного свойства.
  5. Запустите runtime-валидатор на старом и новом ответе. Ошибка должна указывать путь к полю и причину отказа.
  6. Прогоните consumer contract test со старым клиентом. Проверяйте не только десериализацию, но и ветку поведения для каждого допустимого значения.
  7. Проверьте отрицательный путь: неизвестное поле, пропущенное поле, неверный тип, неизвестное enum-значение, 403 и конфликт версии. Для каждого случая зафиксируйте ожидаемый статус.
  8. Если изменение несовместимо, выберите действие: сохранить старое поле, добавить новое рядом, открыть период deprecated или выпустить новую версию. Запишите условие удаления.
\\n

Где заканчивается JSON Schema

\\n

Схема хорошо описывает типы, обязательность и ограничения документа. Она может запретить лишние поля или определить ветвление по значению. Но она не видит пользователя, базу и время. Ответ state: active может быть синтаксически правильным, хотя запись уже заблокирована. Значение revision: 4 не доказывает, что обновление с ревизией 3 ещё допустимо.

\\n

Состояние требует отдельного протокола. Для конкурентного обновления подойдут версия ресурса и условный запрос с If-Match; для права — проверка роли до изменения; для отсутствующего ресурса — договорённый статус 404. Не превращайте 409 в 400: клиенту нужен сигнал, что запрос сформирован правильно, но состояние изменилось. Не повторяйте 403 автоматически: повтор не добавит прав.

\\n

Есть и отрицательный путь для самой проверки. Схема может пройти, а реальный handler — вернуть другой ответ в исключении. Поэтому contract test должен вызвать маршрут, проверить статус, заголовок и тело. Runtime-проверка должна работать на фактическом ответе, а не только на вручную собранном объекте. Это снижает конкретный риск, но не доказывает, что список потребителей полон.

\\n

Ограничения и критерий готовности

\\n

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

\\n

Изменение готово к выпуску, если команда может показать четыре доказательства: новая форма проходит schema- и runtime-проверку; старый клиент проходит consumer contract test; отрицательные случаи возвращают согласованные статусы и причины; для breaking change указаны версия, период совместимости и проверяемое условие удаления. Если хотя бы одного доказательства нет, результат следует считать непроверенным, даже когда сборка завершилась успешно.

\\n

Проверяемые источники

" + "contentHtml": "

Сервис возвращает HTTP 200, но клиент падает при разборе ответа. В логах появляется ошибка чтения поля, неизвестное значение перечисления или попытка вызвать метод у числа вместо строки. Сервер считает запрос успешным, а потребитель — нет. Цена ошибки быстро становится операционной: приходится искать версии клиента, решать, можно ли откатить сервер, и проверять, не появились ли уже записи по новой схеме.

\n

Причина часто не в синтаксисе JSON. Команда меняет ответ как внутреннюю модель и не замечает внешних потребителей: удаляет свойство, меняет тип, сужает перечисление или объявляет новое свойство обязательным. Поэтому проверять нужно не только «валиден ли JSON», но и может ли прежний клиент разобрать ответ и выбрать ту же ветку поведения. Ниже — небольшой контракт для GET /customers/{id}, воспроизводимая проверка и критерий выпуска.

\n

Контракт начинается с границы HTTP

\n

До обсуждения полей зафиксируйте операцию: метод, путь, допустимый статус, Content-Type и тело ответа. OpenAPI описывает HTTP-интерфейс так, чтобы его могли читать люди и инструменты; это удобный источник договорённости, но не телеметрия реального сервера. Если handler иногда отвечает HTML-страницей ошибки или другой схемой при том же статусе, один файл OpenAPI этого не обнаружит.

\n

В примере успешное представление клиента имеет три обязательных поля. id — непустая строка, revision — положительное целое, state — одно из двух значений. Это именно внешний формат, а не копия таблицы в базе данных. Внутреннее поле updatedAt можно не публиковать; наоборот, публичное revision может быть вычисляемым. Такое разделение помогает не вынести внутреннюю миграцию наружу случайным изменением DTO.

\n
GET /customers/{id}\nAccept: application/json\n\n200 OK\nContent-Type: application/json\n\n{\n  "id": "customer-17",\n  "revision": 4,\n  "state": "active"\n}
\n

HTTP 200 говорит о результате операции на уровне протокола, но не обещает, что конкретная библиотека десериализации примет все значения. Клиент может строить URL из id, сравнивать ревизии или выбирать экран по state. Совместимость — это сохранение тех свойств и значений, на которые реально опирается старый потребитель.

\n

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

\n

Термин breaking change нельзя применять к любому diff. Риск зависит от направления обмена и поведения клиента. Добавление необязательного свойства в ответ обычно переживает tolerant-клиент, но строгий декодер может отклонить неизвестное поле. Добавление нового значения enum не меняет JSON-тип, однако ломает закрытый switch, если клиент не имеет безопасной ветки по умолчанию. Поэтому таблица ниже — матрица для проверки, а не автоматический вердикт для всех библиотек.

\n
Изменение ответа, риск и доказательство совместимости
ИзменениеОбычный рискЧто проверитьРешение
Удалено свойствоBreakingЧтение поля, мапперы и fixtures старых клиентовСохранить поле на период совместимости или выпустить версию
Тип изменён: строка стала числомBreakingДесериализация и сравнения в старом клиентеДобавить новое свойство с новым типом
Добавлено обязательное свойство в ответBreaking для строгого декодераОбработка отсутствия поля и правила схемы клиентаСогласовать режим декодера; при запрете неизвестных полей — сменить версию
Добавлено новое значение enumУсловный breakingВетки старого клиента на каждом значенииРасширить обработчик либо не отправлять значение старой версии
Добавлено необязательное свойствоОбычно совместимоЗапрет неизвестных полей и влияние на размер ответаОставить расширение и добавить consumer-тест
Изменён смысл прежнего значенияСкрытый breakingПоведение, а не только JSON SchemaСохранить смысл или переименовать поле
\n

Отдельно проверяйте статус и заголовки. Ответ 404, который превратился в 200 с объектом ошибки, может сломать клиент раньше, чем тот доберётся до тела. И наоборот, формально одинаковый JSON при смене семантики статуса изменит ветку повторов и отображение ошибки. Для каждого исхода задайте точную пару «статус — форма тела».

\n

Проверяем форму до бизнес-правила

\n

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

\n

Ниже — намеренно маленький валидатор на уже разобранном объекте. Он не исправляет ответ молча и не подставляет отсутствующую ревизию. Для production-кода понадобятся проверка фактического HTTP-ответа, единый формат ошибок и согласованный с командой способ обработки лишних полей.

\n
function validateCustomerResponse(payload) {\n  if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {\n    return { ok: false, reason: 'body-must-be-object' };\n  }\n\n  if (typeof payload.id !== 'string' || payload.id.length === 0) {\n    return { ok: false, reason: 'id-must-be-non-empty-string' };\n  }\n\n  if (!Number.isInteger(payload.revision) || payload.revision < 1) {\n    return { ok: false, reason: 'revision-must-be-positive-integer' };\n  }\n\n  if (!['active', 'blocked'].includes(payload.state)) {\n    return { ok: false, reason: 'state-is-outside-enum' };\n  }\n\n  return {\n    ok: true,\n    value: { id: payload.id, revision: payload.revision, state: payload.state },\n  };\n}\n\nconst accepted = validateCustomerResponse({\n  id: 'customer-17', revision: 4, state: 'active',\n});\nconst wrongType = validateCustomerResponse({\n  id: 'customer-17', revision: '4', state: 'active',\n});\nconst unknownState = validateCustomerResponse({\n  id: 'customer-17', revision: 4, state: 'deleted',\n});\n\nconsole.assert(accepted.ok === true);\nconsole.assert(wrongType.reason === 'revision-must-be-positive-integer');\nconsole.assert(unknownState.reason === 'state-is-outside-enum');\nconsole.log('contract checks passed');
\n

Скопируйте блок в файл contract-check.mjs и выполните node contract-check.mjs. Нулевой код завершения доказывает только три перечисленных свойства функции. Он не доказывает, что handler действительно вызывает эту функцию или что сериализатор не меняет данные после проверки. Именно поэтому проверку объекта дополняют тестом маршрута с настоящим статусом, заголовком и телом.

\n
\"Поток
У совместимости три разные точки доказательства: форма ответа, поведение потребителя и контекст операции. Прохождение первой точки не подтверждает право доступа, актуальность данных или корректность бизнес-перехода.
\n

Consumer-тест проверяет не только декодирование

\n

Тест потребителя должен запускать старый клиент против нового ответа. Успешная десериализация недостаточна: нужно пройти ветку, которая использует поле. Для state это означает проверить active, blocked и поведение на неизвестном значении, если сервер имеет право его прислать. Для удаляемого свойства — убедиться, что старый клиент не строит на его отсутствии неверное значение по умолчанию.

\n

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

\n

Учитывайте настройки декодера. В одном клиенте неизвестные свойства игнорируются, в другом запрещены; одни библиотеки превращают число в строку, другие требуют точного типа. Не называйте изменение совместимым по опыту одной реализации. Зафиксируйте режим парсера и повторите тест на минимальной поддерживаемой версии клиента.

\n

Воспроизводимый порядок проверки

\n
  1. Опишите endpoint, метод, допустимые статусы, заголовки и media type. Отделите публичное представление от внутренней модели.
  2. Сохраните текущий контракт как набор схем и примеров: успешный ответ, отсутствие записи, отказ в доступе и конфликт версии, если эти исходы предусмотрены.
  3. Соберите список потребителей и версий. Ищите чтения полей, enum-ветвления, строгие декодеры, DTO-мапперы и генерацию клиентов.
  4. Сравните старую и новую формы. Отдельно пометьте удаление, изменение типа, обязательность, enum, статус, заголовки и изменение смысла.
  5. Запустите схему и runtime-проверку на реальном сериализованном ответе. Не подменяйте HTTP-ответ вручную собранным объектом без отдельного теста сериализации.
  6. Прогоните consumer-тест на старой версии клиента. Проверяйте результат поведения и отрицательные случаи, а не только отсутствие исключения.
  7. Для несовместимого diff выберите одно действие: сохранить старое свойство, добавить новое рядом, открыть период совместимости или выпустить отдельную версию. Назначьте измеримое условие удаления.
  8. После выпуска наблюдайте использование старого поля и долю старых клиентов. Удаляйте deprecated-часть только после подтверждения, что условие выполнено.
\n

Каждый шаг должен оставлять артефакт: diff схемы, fixture, результат теста или метрику. Фраза «клиенты не жаловались» не является доказательством: она не показывает покрытые версии, редкие ветки и неактивных потребителей.

\n

Статус, версия и состояние — разные решения

\n

Схема ответа не заменяет протокол ошибки. Если запрос сформирован правильно, но ресурс изменился между чтением и записью, клиенту нужен сигнал конфликта состояния, а не сообщение о неверном JSON. RFC 9110 описывает условные запросы и заголовок If-Match; его можно использовать как часть отдельного контракта конкурентного обновления, если сервер проверяет условие до изменения.

\n

Авторизация, наличие ресурса и конкурентная версия имеют разные причины и обычно разные статусы. Нельзя выводить право доступа из того, что тело прошло схему. Нельзя считать revision: 4 доказательством, что обновление с ревизией 4 разрешено сейчас: это значение становится полезным только в договорённом протоколе проверки версии.

\n

Ограничение применимости здесь принципиальное: показанный валидатор не проверяет OpenAPI-документ, правила JSON Schema, права, базу, транзакцию, сетевой таймаут или полноту списка потребителей. Он предотвращает конкретные ошибки формы в учебной границе. Для выпуска нужны интеграционный маршрут, старый клиент и наблюдаемое правило удаления.

\n

Критерий готовности к выпуску

\n

Изменение ответа можно считать проверенным, когда команда показывает четыре независимых результата. Новая форма проходит схему и runtime-проверку на фактическом HTTP-ответе. Старый поддерживаемый клиент разбирает ответ и выполняет ожидаемую ветку. Отрицательные случаи имеют согласованные статусы и тела. Если diff несовместим, описаны версия или период совместимости и измеримое условие удаления.

\n

Если зелёный результат есть только у unit-теста валидатора, это ещё не совместимость API. Если схема не описывает смысл поля, добавьте поведенческий consumer-тест. Если неизвестны потребители, не обещайте безопасное удаление: сначала соберите сигнал использования или выберите версионирование. Такой критерий делает решение проверяемым и оставляет видимой цену неизвестности.

\n

Проверяемые источники

" } diff --git a/editorial/agent-rewrites/013.json b/editorial/agent-rewrites/013.json index 86b3f09..950e30b 100644 --- a/editorial/agent-rewrites/013.json +++ b/editorial/agent-rewrites/013.json @@ -2,6 +2,6 @@ "index": 13, "slug": "editorial-2027-08-field-security-capstone", "title": "Авторизация, которую можно доказать: от симптома до отрицательного теста", - "excerpt": "Доступ к своему профилю разрешён, к чужому — тоже. Разбираем, как связать security-требование, объектную политику, HTTP-проверку и доказательство отказа.", - "contentHtml": "

Пользователь открывает свой профиль, а затем меняет один идентификатор в URL и получает профиль другого пользователя. В журналах виден успешный ответ 200. В интерфейсе нет кнопки для чужого объекта, поэтому ручная проверка проходит. Цена ошибки — горизонтальная эскалация привилегий: один аккаунт читает или меняет данные другого. Исправление XSS в форме не закрывает этот путь. Экран может быть безопасным, а endpoint — нет.

\n

Тезис статьи простой: security-проверка готова только тогда, когда требование связано с конкретным субъектом, объектом и действием, а разрешение и отказ подтверждены на границе HTTP. Строка «авторизация проверена» ничего не доказывает. Доказательство содержит вход, ожидаемый ответ, фактический ответ и понятную причину расхождения.

\n

Почему happy path вводит в заблуждение

\n

Проверка владельца обычно начинается с положительного сценария. Пользователь u-1 запрашивает profile u-1 и получает 200. Этот тест показывает, что легитимный запрос работает. Он не показывает, что policy остановит profile u-2. Без отрицательной строки система может разрешать оба запроса.

\n

Причина часто появляется на стыке слоёв. Handler получает id из URL. Middleware проверяет, что токен действителен. Репозиторий ищет запись по одному id. Если проверка владельца не входит в запрос или выполняется после чтения, соседний объект уже попал в ответ. Кэш способен усилить ошибку: ответ для u-1 сохранится под ключом profile:42 и станет доступен u-2.

\n

Нужна объектная проверка. Субъект приходит из проверенного контекста сессии или токена. Объект приходит из маршрута и базы. Действие задаёт endpoint. Решение принимает код рядом с границей доступа, а не только компонент интерфейса.

\n

Требование превращается в матрицу

\n

Фраза «пользователь видит только свой профиль» слишком короткая для теста. Разложите её на четыре поля: кто действует, что делает, над каким объектом и какой результат допустим. Для профиля минимальная политика выглядит так: user может read свой profile; user не может read чужой profile; неизвестная роль получает deny; admin может read audit, если это входит в контракт.

\n
Минимальная матрица политики профиля
СубъектДействиеОбъектОжидаемый результат
user u-1readprofile u-1allow, 200
user u-1readprofile u-2deny, 403 или 404
user u-1readauditdeny, 403 или 404
unknownreadprofile u-1deny, 401 или 403
\n

Статус зависит от контракта. 403 сообщает, что запрос распознан, но запрещён. 404 иногда скрывает существование чужого объекта. Важно не выбрать «правильный» код вообще, а закрепить один вариант для конкретного endpoint и проверить его на внешней границе.

\n

Идентификатор требования должен быть стабильным. Например, AUTH-PROFILE-01 связывает описание политики, тесты и запись изменения. Если требования ссылаются на OWASP ASVS, фиксируйте версию стандарта в ссылке. Идентификатор без версии может начать означать другой текст после обновления стандарта.

\n

Механизм: deny по умолчанию и проверка владения

\n

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

\n
function decide({ role, subjectId, action, resource }) {\n  if (!role || !subjectId || !resource) {\n    return { status: 'deny', reason: 'invalid-input' };\n  }\n\n  if (role === 'admin' && action === 'read' && resource.kind === 'audit') {\n    return { status: 'allow', reason: 'role-permission' };\n  }\n\n  if (role === 'user' && action === 'read' &&\n      resource.kind === 'profile' && resource.ownerId === subjectId) {\n    return { status: 'allow', reason: 'object-ownership' };\n  }\n\n  return { status: 'deny', reason: 'default-deny' };\n}
\n

Код выше — учебный пример. Он не является готовым middleware и не проверяет токен, CSRF, tenant, срок сессии, rate limit, кэш или журналирование. Его задача — показать форму решения: функция принимает субъект, действие и объект; результат содержит статус и безопасную причину. Не передавайте в клиент внутренние сведения о policy. Причину используйте внутри теста и журнала с учётом правил о чувствительных данных.

\n

В реальном handler сначала извлеките субъект из уже проверенного контекста, затем загрузите объект с учётом tenant и вызовите policy. Не принимайте ownerId из тела запроса как доказательство владения. Клиент может изменить это поле. Источник владельца — доверенная запись на сервере.

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

Тестируем отказ как ожидаемый результат

\n

Отказ не должен считаться исключением теста. Он должен быть ожидаемым результатом конкретного входа. Для каждой строки зафиксируйте имя, вход и результат. Так падение отвечает на вопрос: policy разрешила чужой объект, middleware потерял subject или handler вернул неверный статус.

\n
import assert from 'node:assert/strict';\n\nconst cases = [\n  ['owner reads own profile',\n    { role: 'user', subjectId: 'u-1', action: 'read',\n      resource: { kind: 'profile', ownerId: 'u-1' } },\n    { status: 'allow', reason: 'object-ownership' }],\n  ['owner cannot read foreign profile',\n    { role: 'user', subjectId: 'u-1', action: 'read',\n      resource: { kind: 'profile', ownerId: 'u-2' } },\n    { status: 'deny', reason: 'default-deny' }],\n  ['unknown role is denied',\n    { role: 'guest', subjectId: 'u-1', action: 'read',\n      resource: { kind: 'profile', ownerId: 'u-1' } },\n    { status: 'deny', reason: 'default-deny' }],\n];\n\nfor (const [name, input, expected] of cases) {\n  assert.deepEqual(decide(input), expected, name);\n  console.log('PASS', name);\n}
\n

Этот фрагмент также учебный. Его можно выполнить только после добавления функции decide из предыдущего фрагмента. Он не обращается к базе и не доказывает безопасность HTTP-слоя. Если такой unit-тест зелёный, это доказывает только выбранные ветки функции. Следующий тест должен вызвать реальный handler с двумя субъектами и двумя объектами.

\n

Симптом → причина → проверка → действие

\n
Диагностика типовых расхождений
СимптомПричинаПроверкаДействие
Чужой профиль отвечает 200Проверили токен, но не владельца объектаПовторить запрос с u-1 к profile u-2Добавить object-level deny в handler или policy
User читает auditРоль проверяют по наличию, а не по разрешениюЗапросить ресурс с user и adminСделать список разрешённых role/action/resource явным
Неизвестная роль получает доступВетка по умолчанию разрешает запросПодать роль guest или пустую рольВернуть deny до всех allow-веток
После исправления UI тест зелёный, API уязвимПроверяли только скрытую кнопкуВызвать endpoint напрямую без браузерного интерфейсаПеренести контроль на серверную границу и добавить HTTP-тест
Иногда виден чужой ответКлюч кэша не содержит tenant или subjectПовторить запрос после прогрева кэша разными пользователямиРазделить ключи или запретить кэширование приватного ответа
\n

Порядок проверки

\n
  1. Выберите один endpoint, который возвращает или меняет объект по идентификатору.
  2. Запишите субъект, действие, объект и внешний результат в строке требования.
  3. Найдите источник каждого поля. Subject должен прийти из доверенного контекста, owner — из серверной записи, action — из маршрута.
  4. Добавьте один разрешённый случай и минимум три отказа: чужой объект, неподходящая роль и неизвестный объект или действие.
  5. Проверьте policy отдельно, затем вызовите handler напрямую без UI.
  6. Проверьте кэш, tenant-границу, сериализацию ошибки и отсутствие утечки существования объекта.
  7. Сохраните фактические статус, тело ответа и имя проверки. При расхождении исправьте policy или контракт, а не удаляйте отрицательную строку.
\n

Ограничения и отрицательный путь

\n

Матрица не заменяет модель угроз. Она не проверяет CSRF для изменяющего запроса, подделку токена, SSRF, загрузку файлов, гонки, обход маршрутизации и настройку reverse proxy. Для multi-tenant системы добавьте tenant в субъект и объект. Для массовых операций проверьте каждый объект, а не только первый. Для файлов отдельная проверка должна учитывать тип содержимого, имя, хранение вне webroot и выдачу через авторизованный обработчик.

\n

Не считайте зелёный unit-тест доказательством всей защиты. Отрицательный путь может сломаться между слоями: policy вернула deny, но adapter превратил его в 200; handler вернул 403, но кэш отдал старый 200; база загрузила запись другого tenant до проверки. Поэтому критерий должен проходить через реальный маршрут.

\n

Проверяемый критерий готовности

\n

Для выбранного endpoint есть версия требования, матрица входов и автоматическая проверка. Запрос владельца получает закреплённый allow-ответ. Запрос к чужому объекту, неизвестная роль и недопустимое действие получают закреплённый deny-ответ. Прямой HTTP-вызов подтверждает это без UI. Повторная проверка после прогрева кэша не меняет результат между субъектами. В логе остаётся безопасный идентификатор проверки, но не секрет и не лишние персональные данные.

\n

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

\n

Проверяемые источники

" + "excerpt": "Как связать security-требование, субъекта, объект и действие, а затем доказать на HTTP-границе, что чужой идентификатор не даёт доступ.", + "contentHtml": "

Пользователь открывает свой профиль, меняет идентификатор в URL и получает профиль другого пользователя. Ответ — 200, токен действителен, а интерфейс не показывает кнопку для чужого объекта. Цена ошибки — горизонтальная эскалация: один аккаунт читает или меняет данные другого. Исправление XSS в форме этот путь не закрывает. Экран может быть аккуратным, а endpoint — уязвимым.

\n

Разберём узкую задачу: endpoint принимает ссылку на объект и должен решить, может ли конкретный субъект выполнить конкретное действие. Результат будет считаться доказанным только при совпадении четырёх вещей: требования, источника входных данных, фактического решения и внешнего HTTP-ответа. Это не аудит всего приложения и не обещание полной безопасности.

\n

Почему успешный вход ничего не доказывает

\n

Аутентификация отвечает на вопрос «кто отправил запрос». Авторизация отвечает на другой вопрос: «может ли этот субъект выполнить это действие над этим объектом». Валидная сессия решает только первую часть. Если handler получает id из URL, а затем делает поиск только по этому id, он может вернуть запись, которая субъекту не принадлежит.

\n

Это классический разрыв между доступом к функции и доступом к объекту. Пользователь вправе вызвать GET /profiles/:id, но не вправе выбрать любой :id. Если он меняет параметр и получает чужую запись, это object-level проблема; если обычный пользователь вызывает административный endpoint, это уже function-level проблема. Названия полезны только тогда, когда ведут к разным проверкам.

\n

Скрытая ссылка, disabled-кнопка и проверка в браузере не являются границей доверия. Запрос можно повторить через HTTP-клиент. Сервер должен проверить право в каждом пути, который читает, изменяет или удаляет объект по данным клиента. Непредсказуемый UUID уменьшает угадывание, но не превращает отсутствие policy в разрешение.

\n

Требование превращается в проверяемую строку

\n

Фраза «пользователь видит только свой профиль» слишком коротка для теста. Зафиксируем контракт одного read-only endpoint-а: аутентифицированный user читает профиль своего tenant-а, чужой профиль получает отказ, а запрос без действующих credentials не доходит до object policy. Для примера выбираем единый внешний ответ: 200 для allow, 403 для authenticated deny и 401 для отсутствующей или недействительной аутентификации. Если продукт скрывает существование чужого объекта кодом 404, это должна быть отдельная осознанная версия контракта, одинаковая в матрице и тестах.

\n
Матрица входов для endpoint-а профиля
СубъектДействиеОбъектОжидаемый ответДоверенный источник
u-1, t-1readprofile u-1, t-1allow, 200session + database
u-1, t-1readprofile u-2, t-1deny, 403session + database
u-1, t-1readprofile u-3, t-2deny, 403session + database
нет valid credentialsreadprofile u-1, t-1deny, 401auth layer
u-1, t-1deleteprofile u-1, t-1deny, 403route policy
\n

Статусы здесь не взяты «по привычке». Согласно RFC 9110, 401 означает отсутствие действительных authentication credentials и требует WWW-Authenticate; 403 означает, что сервер понял запрос, но отказывается его выполнять; сервер может использовать 404, если не хочет раскрывать существование запрещённого ресурса. Поэтому в реальном проекте сначала фиксируют policy и модель угроз, а уже потом выбирают публичный код.

\n

Где должен находиться контроль

\n

Нарисуйте путь данных до того, как писать условие. subjectId, роль и tenant должны прийти из проверенного контекста аутентификации. Идентификатор ресурса приходит из маршрута, но сам объект и его владелец загружаются сервером. Действие выводится из маршрута и метода, а не из поля, которое клиент может заменить. Для multi-tenant системы tenant входит в область выборки, иначе проверка владельца может оказаться слишком поздней.

\n

Практически это означает: не делайте сначала общий запрос «найди профиль по id», а затем не решайте судьбу уже загруженной записи в случайном слое. Если хранилище позволяет, ограничьте выборку субъектом и tenant-ом сразу. Если нужна отдельная policy, передайте ей server-side resource. ownerId из JSON описывает желание клиента, но не доказывает владение.

\n
function authorize({ role, subjectId, tenantId, action, resource }) {\n  if (!role || !subjectId || !tenantId || !resource) {\n    return { decision: 'deny', reason: 'incomplete-context' };\n  }\n\n  if (\n    role === 'user' &&\n    action === 'read' &&\n    resource.kind === 'profile' &&\n    resource.tenantId === tenantId &&\n    resource.ownerId === subjectId\n  ) {\n    return { decision: 'allow', reason: 'same-tenant-owner' };\n  }\n\n  return { decision: 'deny', reason: 'default-deny' };\n}
\n

Это учебная policy-функция, а не готовое middleware. Она не проверяет подпись токена, срок сессии, CSRF, rate limit, кэш или журналирование. Её полезная граница уже видна: решение зависит от роли, действия, tenant и server-side владельца; неизвестная комбинация не проходит через случайную ветку allow.

\n
Цикл доказательства авторизации: требование связывается с субъектом и объектом, затем проходит отрицательный HTTP-тест и возвращается в разбор при расхождении.
Проверка замыкается только после сравнения ожидаемого и фактического HTTP-ответа. Unit-решение policy — промежуточное evidence, а не доказательство маршрута.
\n

Отрицательный тест должен быть исполняемым

\n

Happy path показывает, что владелец не заблокирован. Он не показывает, что соседний объект закрыт. Минимальный тест держит рядом имя случая, вход и ожидаемое решение. Ниже полностью самодостаточный файл для Node.js: сохраните его как authorization-policy.test.js и выполните командой node authorization-policy.test.js. В нём нет сети и реальных данных, поэтому зелёный результат относится только к этой функции.

\n
const assert = require('node:assert/strict');\n\nfunction authorize({ role, subjectId, tenantId, action, resource }) {\n  if (!role || !subjectId || !tenantId || !resource) {\n    return { decision: 'deny', reason: 'incomplete-context' };\n  }\n\n  if (role === 'user' && action === 'read' &&\n      resource.kind === 'profile' &&\n      resource.tenantId === tenantId &&\n      resource.ownerId === subjectId) {\n    return { decision: 'allow', reason: 'same-tenant-owner' };\n  }\n\n  return { decision: 'deny', reason: 'default-deny' };\n}\n\nconst cases = [\n  ['owner reads own profile',\n    { role: 'user', subjectId: 'u-1', tenantId: 't-1', action: 'read',\n      resource: { kind: 'profile', ownerId: 'u-1', tenantId: 't-1' } },\n    { decision: 'allow', reason: 'same-tenant-owner' }],\n  ['owner cannot read foreign profile',\n    { role: 'user', subjectId: 'u-1', tenantId: 't-1', action: 'read',\n      resource: { kind: 'profile', ownerId: 'u-2', tenantId: 't-1' } },\n    { decision: 'deny', reason: 'default-deny' }],\n  ['cross-tenant profile is denied',\n    { role: 'user', subjectId: 'u-1', tenantId: 't-1', action: 'read',\n      resource: { kind: 'profile', ownerId: 'u-1', tenantId: 't-2' } },\n    { decision: 'deny', reason: 'default-deny' }],\n  ['unknown role is denied',\n    { role: 'guest', subjectId: 'u-1', tenantId: 't-1', action: 'read',\n      resource: { kind: 'profile', ownerId: 'u-1', tenantId: 't-1' } },\n    { decision: 'deny', reason: 'default-deny' }],\n  ['unsupported action is denied',\n    { role: 'user', subjectId: 'u-1', tenantId: 't-1', action: 'delete',\n      resource: { kind: 'profile', ownerId: 'u-1', tenantId: 't-1' } },\n    { decision: 'deny', reason: 'default-deny' }],\n];\n\nfor (const [name, input, expected] of cases) {\n  assert.deepEqual(authorize(input), expected, name);\n  console.log('PASS', name);\n}
\n

Этот тест теперь воспроизводим как unit-проверка, но не маскирует границу. Следующий слой должен вызвать реальный handler и передать ему две тестовые identities, два tenant-а и два объекта. Для read-only endpoint-а безопасный шаблон запроса выглядит так:

\n
curl -sS -i \\\n  -H 'Authorization: Bearer <test-token-u-1>' \\\n  'https://test.example.test/api/profiles/u-2'
\n

На тестовом окружении ожидайте выбранный контракт, здесь — 403 и отсутствие данных u-2 в теле. Второй запрос к u-1 должен дать 200, запрос без credentials — 401 с WWW-Authenticate. Не подставляйте реальные токены и не проверяйте чужие объекты без письменного разрешения: воспроизводимость не расширяет область допустимых действий.

\n

Проверяем не только policy

\n

Расхождение между unit и HTTP возникает на стыках. Adapter может превратить deny в 200, serializer — добавить лишнее поле, а кэш — вернуть ответ, созданный для другого субъекта. Для приватного ответа ключ должен учитывать все атрибуты, влияющие на право, включая tenant и subject, либо ответ не должен кэшироваться общим слоем. Это решение проверяют фактическим повтором, а не чтением названия ключа.

\n
Симптом → причина → проверка → действие
СимптомВероятная причинаПроверкаДействие
Чужой профиль отвечает 200Проверили токен, но не объектu-1 запрашивает профиль u-2 напрямуюДобавить object-level deny и HTTP-тест
u-1 видит объект tenant t-2Tenant ограничили после общего чтенияПовторить запрос двумя tenant-амиВключить tenant в выборку и policy
Policy вернула deny, HTTP дал 200Adapter или handler потерял решениеСравнить policy result, статус и bodyСделать mapping явным и тестируемым
Чужой ответ появляется после прогрева кэшаКлюч не содержит permission contextПоменять субъекта после первого запросаРазделить ключ или отключить общий cache
403 раскрывает существование записиПубличный ответ повторяет внутреннюю причинуСравнить чужой и отсутствующий объектВыбрать 403 или 404 по модели угроз; тело сделать одинаково безопасным
UI-тест зелёный, endpoint уязвимПроверяли только видимость кнопкиВызвать маршрут без браузераОставить HTTP-проверку в CI
\n

Логи тоже относятся к контракту доказательства. Записывайте идентификатор тестового случая, результат и безопасный correlation id. Не кладите в журнал bearer token, пароль, полный URL с секретом или лишние персональные данные. Внешний ответ должен помогать клиенту, а внутренний reason — расследованию; это не одно и то же поле.

\n

Порядок проверки одного endpoint-а

\n
  1. Запишите маршрут, HTTP-метод, действие и тип объекта. Выберите тестовые identities, которыми разрешено пользоваться.
  2. Составьте матрицу: свой объект, чужой объект того же tenant-а, объект другого tenant-а, отсутствующая аутентификация и недопустимое действие.
  3. Назначьте источник каждого поля. Subject и роль приходят из auth context, owner и tenant — из серверного объекта, action — из маршрута.
  4. Проверьте запрос к хранилищу. Убедитесь, что tenant boundary не добавляется после выдачи данных и что объект не строится из client body.
  5. Напишите unit-тест policy с явным default deny. Отдельно проверьте реальный handler и сериализацию.
  6. Повторите все строки через HTTP без UI. Сохраните статус, безопасное тело, заголовки и идентификатор случая.
  7. Прогрейте кэш и повторите пары разными субъектами. Для mutation добавьте проверку отсутствия изменения после deny.
  8. Зафиксируйте расхождение как дефект policy, data access, mapping, cache или контракта. Не удаляйте отрицательный тест ради зелёного pipeline.
\n

Ограничения и критерий готовности

\n

Эта схема не проверяет подпись и срок жизни токена, MFA, CSRF, права на отдельные поля, загрузку файлов, SSRF, rate limit, гонки, репликацию базы, reverse proxy и корректность всех альтернативных маршрутов. Для массового запроса проверяйте каждый объект, а не только первый. Для администратора описывайте отдельные grants: роль сама по себе не означает право читать всё.

\n

Готовность одного endpoint-а можно сформулировать строго: есть версия требования, доверенный источник subject/tenant/owner/action, серверная object-level проверка, unit-отказ и успешный HTTP-тест владельца. Прямой запрос к чужому и cross-tenant объекту возвращает закреплённый deny-ответ; запрос без credentials проходит auth-контракт; после кэша результат не меняется между субъектами. Это доказательство выбранной границы, а не сертификат безопасности приложения.

\n

Проверяемые источники

Первоисточники, связанные с конкретными утверждениями
ИсточникЧто подтверждаетОграничение применимости
OWASP API1:2023 Broken Object Level AuthorizationИзменение object ID может обойти контроль; endpoint, работающий с объектом по client input, должен проверять право на этот объект; нужны тесты authorization.Это категория риска API и рекомендации, а не проверка конкретного приложения и не гарантия покрытия.
OWASP API5:2023 Broken Function Level AuthorizationFunction-level доступ нужно явно разрешать ролям, а неизвестные комбинации отклонять по умолчанию; это отдельная проблема от object-level доступа.Материал не выбирает роли, tenant-модель или публичные HTTP-коды конкретного продукта.
OWASP WSTG-ATHZ-04: Testing for Insecure Direct Object ReferencesНужно картировать прямые ссылки на объекты, менять параметр и сравнивать доступ как минимум для двух пользователей с разными объектами.Latest-версия руководства может обновляться; это методика тестирования, а не compliance standard и не разрешение тестировать чужие системы.
IETF RFC 9110, раздел 15.5Смысл 401, 403 и 404: credentials, отказ в выполнении и допустимое сокрытие существования ресурса; для 401 требуется WWW-Authenticate.RFC описывает семантику HTTP, но не задаёт application policy, модель угроз или выбор между 403 и 404 для конкретного сервиса.
" } diff --git a/editorial/agent-rewrites/014.json b/editorial/agent-rewrites/014.json index 72c4255..c11ba14 100644 --- a/editorial/agent-rewrites/014.json +++ b/editorial/agent-rewrites/014.json @@ -2,6 +2,6 @@ "index": 14, "slug": "editorial-2027-08-mechanism-security-capstone", "title": "Проверка логина не защищает объект: строим deny-by-default", - "excerpt": "Пользователь может быть правильно аутентифицирован и всё равно не иметь права читать выбранную запись. Разбираем объектную авторизацию, проверку владельца и отрицательный путь от URL до ответа 403.", - "contentHtml": "

Пользователь входит в систему, открывает /profile?id=u-1, меняет один символ и получает профиль u-2. Токен остаётся действительным. Сервер проверяет его наличие и отдаёт найденную запись. Симптом выглядит как обычный доступ к странице, но это горизонтальная эскалация прав.

Цена ошибки — утечка персональных данных, изменение чужих объектов и потеря границы между tenant-ами. Чем больше endpoint-ов строится по схеме «взяли id из URL, нашли запись, вернули ответ», тем больше таких точек появляется. Скрытая кнопка в интерфейсе не помогает: запрос можно повторить вручную.

Тезис: логин устанавливает субъекта, но не разрешение

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

Надёжное решение принимает четыре значения: субъект, действие, объект и контекст политики. Субъект приходит из проверенной сессии или токена. Действие выводится из маршрута и HTTP-метода. Объект загружается сервером. Владелец, tenant и чувствительность объекта берутся из доверенных данных, а не из тела запроса. Неизвестная комбинация получает deny.

Механизм объектной проверки

Сначала middleware или слой сессии устанавливает subjectId и роль. Затем handler разбирает путь и получает идентификатор ресурса. Репозиторий возвращает объект вместе с его владельцем и tenant-ом. Policy layer сравнивает эти поля с субъектом и разрешает только явно описанные действия. UI может скрыть недоступную кнопку, но решение всё равно принимает сервер.

Правило владельца нельзя заменить проверкой роли. Два пользователя могут иметь одну роль user, но видеть разные профили. Роль говорит о классе полномочий. Владелец говорит о конкретном объекте. Для администратора нужен отдельный allow-список: «читать аудит» не равно «читать любую персональную запись», а «администратор» не должно означать «разрешено всё».

Нельзя принять ownerId из JSON и использовать его как доказательство владения. Клиент сообщает, какой объект он хочет выбрать. Сервер сам читает владельца из базы или доменного сервиса. Для multi-tenant системы запрос к хранилищу должен сразу включать tenant boundary. Если сначала получить запись без ограничения области, последующая проверка уже может оказаться слишком поздней.

Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Замена id в URL показывает чужой профильПроверили сессию, но не владельца объектаОтправить запрос для своего и соседнего идентификатораСравнивать subjectId с владельцем записи; чужой объект отклонять
Пользователь читает audit endpointРоль проверяют слишком широкоВызвать endpoint с ролью user напрямую, без UIОписать ресурс и действие в отдельном allow-правиле
Неизвестная роль получает 200Ветка по умолчанию пропускает запросУдалить или подменить claim и повторить вызовСделать результатом по умолчанию deny
Чужой ответ появляется после кэшированияКлюч кэша не содержит субъекта или областиПовторить запрос разными субъектами и сравнить телоРазделить кэш по permission context либо не кэшировать ответ
403 раскрывает существование записиВнешний ответ повторяет внутреннюю причинуСравнить ответ для отсутствующего и чужого объектаВыбрать 404 или 403 по модели угроз, причину логировать безопасно
Матрица авторизации связывает субъекта, роль, действие, объект и владельца с решением allow или deny
Решение строится на серверных атрибутах объекта. Изменение идентификатора в запросе не меняет его владельца и tenant.

Учебный endpoint

Ниже — маленький пример на Node.js. Профили хранятся в Map, а субъект и роль передаются заголовками только для учебного сценария. Реальная система должна получать их из проверенной сессии, JWT или другого принятого механизма. Пример не подключается к production и не доказывает безопасность конкретного приложения.

const profiles = new Map([['u-1', { owner: 'u-1', tenant: 't-1' }], ['u-2', { owner: 'u-2', tenant: 't-1' }]]); function decide({ role, subjectId, tenant, resource, action }) { if (role === 'admin' && resource.kind === 'audit' && action === 'read') return { status: 200, reason: 'admin-audit' }; if (role === 'user' && resource.kind === 'profile' && action === 'read' && resource.tenant === tenant && resource.owner === subjectId) return { status: 200, reason: 'owner' }; return { status: 403, reason: 'default-deny' }; } const id = new URL(request.url, 'http://local').searchParams.get('id'); const profile = profiles.get(id); const resource = profile && { kind: 'profile', ...profile }; const result = resource ? decide({ role, subjectId, tenant, resource, action: 'read' }) : { status: 404, reason: 'not-found' };

Условие для профиля проверяет роль, действие, tenant и владельца. Запрос от u-1 к u-1 получает 200. Тот же субъект к u-2 получает 403. Если tenant отличается, результат также deny. Идентификатор из URL только выбирает запись; он не назначает ей владельца.

Проверка существования объекта требует отдельного решения. В примере отсутствующий профиль даёт 404, а найденный чужой — 403. В некоторых системах оба случая наружу превращают в 404, чтобы не раскрывать наличие записи. Это не универсальное правило. Выбор зависит от модели угроз. Внутри сохраняйте короткий класс причины и не пишите в журнал полный токен или секретные параметры.

Отрицательный путь важнее happy path

Разрешённый запрос показывает, что легитимный сценарий работает. Он не показывает, что граница закрыта. Минимальный набор должен включать чужой объект, запрещённое действие, неизвестную роль, другой tenant, отсутствующий ресурс и повторный вызов через прямой HTTP-клиент. Для mutation добавьте проверку метода и защиту от повторной операции. Для чтения проверьте кэш и сериализацию ответа.

Проверяйте policy без интерфейса. Если тест кликает только по видимой кнопке, он не проверяет handler. Отправьте запрос с изменённым id, вручную задайте роль и удалите обязательный claim. Эти входы учебные и не должны содержать реальные идентификаторы или секреты. Их смысл — показать отрицательную ветку, а не воспроизвести доступ к настоящим данным.

Порядок внедрения

  1. Составьте карту одного endpoint-а: субъект, HTTP-метод, действие, объект, tenant и внешний ответ.
  2. Определите доверенный источник каждого поля. Не берите роль, владельца и tenant из пользовательского body.
  3. Загрузите объект в правильной области доступа. Для tenant-системы включите tenant в запрос к хранилищу.
  4. Запишите явные allow-правила для ресурса и действия. Оставьте deny результатом для неизвестной комбинации.
  5. Добавьте тесты своего объекта, чужого объекта, запрещённого действия, неизвестной роли и другой области.
  6. Вызовите handler напрямую, без UI, и проверьте код, тело, кэш и отсутствие лишних данных в ответе.
  7. Настройте безопасное журналирование: причина должна помогать расследованию, но не содержать токены, пароли и полный URL с секретами.
  8. Повторите отрицательные тесты после изменения middleware, репозитория, политики и ключа кэша.

Ограничения и критерий готовности

Учебный код использует заголовки вместо настоящей аутентификации и Map вместо базы. Он не проверяет срок жизни токена, подпись JWT, CSRF, race condition, права на поля, согласованность реплик и поведение прокси. Он также не решает, как кэшировать персональный ответ. Эти вопросы требуют отдельных контрактов и тестов.

Проверка готова для одного endpoint-а, если видны источник subjectId, правило области, серверный способ получения владельца, явное действие и default deny. Интеграционный тест должен показать 200 для разрешённого объекта, отказ для чужого объекта и отказ для неизвестной роли через реальный HTTP-маршрут. Если проходит только unit-тест policy или только проверка UI, работа не готова: граница между запросом, хранилищем и ответом ещё не доказана.

Проверяемые источники

" + "excerpt": "Валидная сессия отвечает только на вопрос «кто отправил запрос». Разбираем объектную авторизацию, границу tenant-а и отрицательный путь от URL до ответа 403 или 404.", + "contentHtml": "

Пользователь входит в систему, открывает /profile?id=u-1, меняет один символ и получает профиль u-2. Токен остаётся действительным, поэтому проверка логина проходит. Ошибка возникает дальше: сервер не проверяет, имеет ли этот субъект право читать выбранный объект. Это горизонтальная эскалация прав.

\n

Цена ошибки измеряется не только одним лишним экраном. В профиле могут оказаться персональные данные, в заказе — адрес и сумма, а в mutation — возможность изменить чужую запись. Скрытая кнопка не закрывает маршрут: запрос повторяется через DevTools, curl или автоматический тест. Поэтому границу нужно проверять там, где сервер загружает объект и формирует ответ.

\n

Логин определяет субъекта, а не разрешение

\n

Аутентификация устанавливает, кто отправил запрос. Авторизация решает, можно ли этому субъекту выполнить конкретное действие над конкретным объектом. Эти проверки связаны, но не заменяют друг друга. Наличие cookie, валидный JWT и роль user ещё не означают право читать любой ресурс с той же ролью.

\n

Для одного решения зафиксируйте четыре входа: субъект, действие, объект и контекст. Субъект берётся из проверенной сессии или токена. Действие выводится из маршрута и HTTP-метода: GET /profile — чтение, PATCH /profile — изменение. Объект выбирается по идентификатору запроса, но его владелец и область берутся из хранилища. Контекстом могут быть tenantId, состояние записи, принадлежность команде или требуемый уровень чувствительности.

\n

Политика должна явно описать разрешённые комбинации. Если роль, действие, тип ресурса или область неизвестны, результатом становится deny. Это и есть deny-by-default: новая ветка не получает доступ только потому, что разработчик забыл добавить условие запрета.

\n

Где проходит серверная граница

\n

Надёжный маршрут начинается с источников доверия. Middleware проверяет сессию и передаёт дальше нормализованный subjectId, роль и, если применимо, tenantId. Handler получает идентификатор объекта из URL. Репозиторий читает запись с ограничением области. Policy layer сопоставляет серверные атрибуты объекта с субъектом и действием. Только после этого сериализатор строит ответ.

\n

Для tenant-системы область нужно включить уже в запрос к хранилищу. Например, выборка должна искать запись по паре id + tenantId, а не сначала получать любой объект по одному id. Это сокращает риск ошибочного использования чужой записи в следующем слое. Но фильтр репозитория не отменяет policy: владелец, роль и разрешённое действие всё равно должны быть частью проверяемого решения.

\n

Клиентский ownerId не является доказательством владения. Клиент может сообщить, какой объект хочет выбрать, но не может назначить себе владельца, tenant или роль. То же правило относится к скрытым полям формы, заголовкам, query-параметрам и данным, которые приходят от другого сервиса без проверки происхождения.

\n

Роль тоже нельзя превращать в универсальный пропуск. Администратору может быть разрешено читать аудит, но не персональные поля; оператору — менять статус заявки, но не владельца. Чем шире правило «admin может всё», тем труднее увидеть, какой объект и какое действие оно открывает. Разделяйте права на ресурс и операцию, а исключения записывайте рядом с их основанием.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Замена id в URL показывает чужой профильСессию проверили, владельца объекта — нетЗапросить свой и соседний идентификатор одной сессиейСверять subjectId с владельцем и областью объекта
Роль user читает audit endpointПроверка роли не связана с ресурсом и действиемВызвать маршрут напрямую, без интерфейсаДобавить отдельное allow-правило для audit:read
Неизвестная роль получает 200Ветка по умолчанию пропускает запросУдалить claim и повторить вызовВернуть отказ для любой неописанной комбинации
Чужой ответ появляется после кэшированияКлюч кэша не учитывает область или permission contextСравнить ответы для двух субъектовРазделить кэш или отключить его для персонального ответа
Ответ раскрывает наличие чужой записиВнешний статус и текст выбраны без модели угрозСравнить отсутствующий и запрещённый объектВыбрать 403 или маскирующий 404 и не раскрывать причину
\n
Матрица объектной авторизации связывает субъект, роль, действие, tenant и владельца записи с решением allow или deny
Идентификатор из URL выбирает объект, но не меняет его владельца и tenant. Решение принимается по серверным атрибутам.
\n

Самодостаточный учебный пример

\n

Следующий фрагмент можно выполнить в Node.js без базы данных. Он моделирует только авторизацию чтения профиля: Map заменяет репозиторий, а объект сессии — результат настоящей проверки учётных данных. В production нельзя принимать сессию из тела запроса или доверять заголовку, который клиент может подменить.

\n
const profiles = new Map([\n  ['u-1', { ownerId: 'u-1', tenantId: 't-1', displayName: 'Ada' }],\n  ['u-2', { ownerId: 'u-2', tenantId: 't-1', displayName: 'Linus' }],\n  ['u-3', { ownerId: 'u-3', tenantId: 't-2', displayName: 'Grace' }],\n]);\n\nfunction authorizeProfileRead(session, requestedId) {\n  const profile = profiles.get(requestedId);\n  if (!profile) return { status: 404, reason: 'not-found' };\n  if (session.role !== 'user') return { status: 403, reason: 'role-denied' };\n  if (profile.tenantId !== session.tenantId) {\n    return { status: 403, reason: 'tenant-denied' };\n  }\n  if (profile.ownerId !== session.subjectId) {\n    return { status: 403, reason: 'owner-denied' };\n  }\n  return {\n    status: 200,\n    body: { id: requestedId, displayName: profile.displayName },\n  };\n}\n\nconst own = authorizeProfileRead(\n  { subjectId: 'u-1', tenantId: 't-1', role: 'user' }, 'u-1');\nconst foreignObject = authorizeProfileRead(\n  { subjectId: 'u-1', tenantId: 't-1', role: 'user' }, 'u-2');\nconst otherTenant = authorizeProfileRead(\n  { subjectId: 'u-1', tenantId: 't-1', role: 'user' }, 'u-3');\n\nconsole.log(own.status, foreignObject.status, otherTenant.status); // 200 403 403
\n

В примере URL-идентификатор передаётся только в requestedId. Субъект, роль и tenant приходят из session, а владелец читается из найденного профиля. Поэтому запрос от u-1 к u-2 не становится разрешённым после замены параметра. Изменение кода на реальный handler должно сохранить ту же последовательность и добавить проверку HTTP-метода, схемы входа и сериализации.

\n

Код намеренно возвращает 403 для найденного, но чужого объекта и 404 для отсутствующего. HTTP Semantics допускает 404, когда сервер не хочет раскрывать существование запрещённого ресурса. Это не автоматическое требование для каждого API: решение зависит от того, нужна ли клиенту разница между «нет записи» и «нет доступа», и что может узнать атакующий по ответу.

\n

Проверяйте отрицательный путь

\n

Успешное чтение собственного профиля доказывает только один allow-сценарий. Оно не показывает, что граница закрыта. Минимальный набор проверок должен содержать чужой объект в том же tenant-е, объект другого tenant-а, запрещённое действие, неизвестную роль, отсутствующий объект и запрос без обязательных учётных данных. Для mutation дополнительно проверяйте, что состояние не изменилось после отказа.

\n

Отправляйте эти запросы напрямую к HTTP-маршруту. UI-тест, который видит только доступную кнопку, не проверяет handler с изменённым id. В ответе проверяйте статус, тело, заголовки и отсутствие лишних полей. Если включён кэш, выполняйте последовательность «субъект A → тот же URL субъект B» и сравнивайте результат. В прокси и CDN отдельно смотрите, не стал ли персональный ответ общим.

\n

Причину отказа можно сохранить в безопасном журнале коротким кодом вроде owner-denied. Не записывайте токены, пароли, полное тело запроса и URL, в котором секрет оказался в query-параметре. Лог должен помогать отличить ошибку политики от отсутствующего объекта, но не становиться вторым каналом утечки.

\n

Порядок внедрения

\n
  1. Выберите один endpoint и опишите его ресурс, метод, действие, субъект, tenant и изменяемые поля.
  2. Назовите доверенный источник каждого значения. Роль, владелец и область не должны приходить из пользовательского body.
  3. Загрузите объект в правильной области доступа; для tenant-системы включите tenant в условие репозитория.
  4. Определите явные allow-правила для каждой пары «ресурс + действие». Всё неизвестное оставьте deny.
  5. Разделите проверку credentials, объектную авторизацию, валидацию входа и сериализацию ответа.
  6. Добавьте allow-тест для своего объекта и deny-тесты для чужого объекта, другой области, запрещённого действия и неизвестной роли.
  7. Вызовите реальный HTTP-маршрут без UI и проверьте статус, тело, побочный эффект, кэш и заголовки.
  8. Зафиксируйте короткую причину решения и список непроверенных соседних маршрутов.
  9. Повторите отрицательные тесты после изменения middleware, репозитория, policy layer, схемы ответа или кэш-ключа.
\n

Ограничения и критерий готовности

\n

Учебный код не проверяет подпись и срок жизни JWT, отзыв сессии, CSRF, права на отдельные поля, race condition, согласованность реплик, GraphQL resolver, WebSocket или фонового потребителя очереди. У каждого канала свой handler и свой объектный контекст. Проверка одного GET не даёт права объявить защищёнными PATCH, экспорт, поиск и административные маршруты.

\n

Фильтр по tenant-у не решает все задачи мультиарендности: остаются ошибки конфигурации, смешение кэшей, фоновые задачи без субъекта и служебные аккаунты. Проверка владельца не решает делегирование, совместный доступ и временные полномочия. Для них нужны отдельные правила и отрицательные тесты, а не расширение условия до «если роль admin».

\n

Endpoint можно считать проверенным только для заявленного контракта, если видны источник субъекта, правило области, серверный способ получения владельца, действие, внешний статус и доказательство отказа. Интеграционный тест должен дать 200 своему объекту, отказать чужому объекту и неизвестной роли через реальный маршрут. Если зелёным остаётся только unit-тест policy или только проверка UI, связь между запросом, хранилищем и ответом ещё не доказана.

\n

Проверяемые источники

\n" } diff --git a/editorial/agent-rewrites/015.json b/editorial/agent-rewrites/015.json index 7067bb5..7c257b5 100644 --- a/editorial/agent-rewrites/015.json +++ b/editorial/agent-rewrites/015.json @@ -3,5 +3,5 @@ "slug": "editorial-2027-08-practice-security-capstone", "title": "SSRF начинается с URL: проверяем адрес до сетевого вызова", "excerpt": "Практическая защита server-side запроса: разбираем URL, применяем точный allowlist, запрещаем обход через credentials и редиректы, а затем ограничиваем сам сетевой вызов.", - "contentHtml": "

Сервис принимает URL картинки и скачивает её на сервере. Пользователь вводит https://cdn.example.test/avatar.jpg, но тот же endpoint может получить https://cdn.example.test@127.0.0.1/admin или адрес внутреннего metadata-сервиса. Если проверка ищет только подстроку https://cdn.example.test, сервер сам отправляет запрос не туда. Цена ошибки — чтение внутреннего ответа, обращение к административному интерфейсу и утечка данных через внешний ответ или журнал.

\n

Тезис простой: SSRF нужно останавливать до сетевого вызова и проверять разобранные поля URL, а не похожесть исходной строки. После этого нужны отдельные ограничения redirect, DNS, IP, порта, времени и размера ответа. Учебный код ниже возвращает решение политики, но не выполняет запрос и не доказывает безопасность конкретной сети.

\n

Как возникает ошибка

\n

URL содержит схему, authority, имя пользователя и пароль, hostname, порт, путь и query. Эти части имеют разную роль. Строка до символа @ может выглядеть как доверенное имя, но фактический host находится после него. В адресе https://cdn.example.test@127.0.0.1/file значение url.hostname — 127.0.0.1. Сравнение исходной строки не отвечает на вопрос, куда подключится клиент.

\n

Первый слой принимает только нужную схему. Для загрузки изображения это обычно https:. Второй слой запрещает username и password. Третий слой сравнивает нормализованный hostname с точным allowlist. Четвёртый слой решает, какие порты и пути разрешены. Только после этих проверок приложение может передать адрес сетевому клиенту.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
«Доверенный» URL обращается к loopbackПроверяли строковый префикс или часть до @Распарсить URL и вывести только hostnameЗапретить credentials и сравнивать фактический host
Проходит похожий доменИспользовали endsWith без границы имениПроверить evil-example.test и sub.example.testРазрешать точное имя или явный суффикс .example.test
Запрос уходит на другой адрес после 302Клиент автоматически следует redirectПерехватить заголовок LocationЗапретить redirect или повторить политику для каждого нового URL
Имя разрешено, IP закрытыйПроверен hostname, но не результат DNSПроверить A и AAAA и диапазоны адресовСверить адреса с политикой и контролировать egress
Разрешённый ответ занимает памятьЕсть allowlist, но нет лимита телаПроверить Content-Length и поток чтенияОстановить чтение после заданного размера и ограничить timeout
\n
\"Проверка
Каждое решение принимается до вызова сети. Отказ возвращает причину, но не передаёт непроверенный адрес следующему слою.
\n

Allowlist должен описывать ресурс

\n

Точный allowlist проще проверить, чем набор отрицательных исключений. Для одного CDN можно разрешить только cdn.example.test. Если нужны поддомены, правило должно различать границу: имя равно example.test или заканчивается на .example.test. Условие hostname.endsWith('example.test') пропустит evil-example.test. Оно также не отвечает, разрешён ли вложенный сервис и кто владеет его DNS-записью.

\n

Не принимайте список разрешённых доменов из запроса. Конфигурация принадлежит серверу и меняется через контролируемую поставку. Сравнивайте hostname после разбора стандартным URL-парсером. Не подставляйте вручную протокол, не вырезайте query регулярным выражением и не используйте отображаемое пользователю значение как доказательство адреса.

\n

Порт нужно ограничить отдельно. Явный :8443 — это не тот же сетевой контракт, что порт 443. Если приложение разрешает нестандартный порт, перечислите его для конкретного hostname. Запретите пустой или неожиданный порт, если клиент или прокси трактует его по-разному. Путь тоже может быть частью политики: CDN может разрешать только каталог изображений, а не весь host.

\n

Учебная проверка без запроса

\n

Функция принимает строку и массив имён. Она возвращает { allowed, reason, href }. Функция не вызывает fetch, не разрешает DNS и не проверяет корпоративный proxy. Поэтому её можно использовать только как маленький учебный пример для проверки порядка решений. Доменные имена в примерах вымышлены и не подтверждают наличие реального сервиса.

\n
function validateRemoteUrl(value, allowedHosts) {\n  let url;\n  try {\n    url = new URL(value);\n  } catch {\n    return { allowed: false, reason: 'invalid-url' };\n  }\n\n  if (url.protocol !== 'https:') {\n    return { allowed: false, reason: 'scheme' };\n  }\n  if (url.username || url.password) {\n    return { allowed: false, reason: 'credentials' };\n  }\n  if (url.port && url.port !== '443') {\n    return { allowed: false, reason: 'port' };\n  }\n  if (!allowedHosts.includes(url.hostname)) {\n    return { allowed: false, reason: 'host' };\n  }\n\n  return { allowed: true, reason: 'allowlist', href: url.href };\n}\n\nvalidateRemoteUrl(\n  'https://cdn.example.test/file.jpg',\n  ['cdn.example.test'],\n);\n// { allowed: true, reason: 'allowlist', href: ... }\n\nvalidateRemoteUrl(\n  'https://cdn.example.test@127.0.0.1/file.jpg',\n  ['cdn.example.test'],\n);\n// { allowed: false, reason: 'credentials' }
\n

Первый пример проходит allowlist. Во втором функция останавливается на credentials. Адрес https://127.0.0.1/file.jpg остановится на hostname. Это отрицательный путь: приложение не должно сначала выполнить запрос, а потом решить, был ли адрес допустим. Не включайте полный входной URL в лог отказа. В нём могут быть пароль, token или query с персональными данными.

\n

Редирект меняет цель

\n

Даже разрешённый CDN может ответить 301 или 302 с новым Location. После redirect исходный allowlist уже не описывает конечную цель. Надёжный вариант для простого endpoint — отключить автоматическое следование и вернуть отказ с классом redirect-not-allowed. Если перенаправление нужно по требованиям продукта, каждый новый URL должен пройти ту же проверку схемы, credentials, host и порта. Ограничьте число переходов.

\n

Проверяйте redirect до чтения тела ответа. Не разрешайте схему, отличную от исходной, если это не входит в явную политику. Не считайте относительный Location безопасным автоматически: его нужно разрешить относительно уже проверенного URL и снова проверить результат. Учебная функция выше redirect не обрабатывает. Это осознанная граница, а не пропущенная ветка.

\n

DNS и сетевой слой

\n

Проверка имени не доказывает, что соединение пойдёт к безопасному IP. DNS может вернуть несколько адресов. Запись может измениться между проверкой и подключением. Возможны IPv4, IPv6, loopback, link-local и приватные диапазоны. Политика должна решить, какие адреса допустимы, и применить это решение ко всем результатам A и AAAA там, где это важно для модели угроз.

\n

В чувствительной сети приложение и egress-шлюз должны дополнять друг друга. Приложение проверяет контракт endpoint. Сетевой слой запрещает выход к metadata, loopback и приватным сегментам, если они не нужны. Ни один слой не должен молча считать другой слой достаточным. Если библиотека клиента кеширует DNS или сама разрешает redirect, это нужно проверить в её документации и тесте.

\n

DNS rebinding и proxy могут изменить момент, в который адрес превращается в соединение. Не обещайте защиту одной функцией validateRemoteUrl. Для конкретного runtime нужен способ связать проверенный адрес с фактическим соединением или вынести запрос в изолированный egress-сервис с собственной политикой.

\n

Ограничения сетевого вызова

\n

Allowlist не ограничивает объём ответа. Сервер может вернуть большой файл, бесконечный поток или медленное тело. Задайте общий deadline, timeout установления соединения, максимальный размер ответа и ограничение числа одновременных загрузок. Проверяйте размер по заголовку, но не доверяйте ему как единственному барьеру: поток нужно прекращать при достижении лимита.

\n

Не принимайте ответ только потому, что его Content-Type похож на изображение. Сначала ограничьте размер и поток, затем проверяйте формат отдельной библиотекой и сохраняйте результат вне webroot по серверному имени. Это уже другая граница системы, но SSRF endpoint часто совмещает загрузку, декодирование и публикацию. Ошибка на одном шаге не должна превращать следующий в обход.

\n

Порядок внедрения

\n
  1. Найдите каждый endpoint, который получает URL или косвенно строит его из пользовательского ввода. Запишите цель запроса и побочный эффект.
  2. Опишите политику в конфигурации: схемы, точные hostname, допустимые порты, пути, redirect, размер и deadline.
  3. Разберите URL стандартным парсером до любого DNS или HTTP-вызова. Отдельно запретите username, password, неожиданные схемы и некорректные порты.
  4. Добавьте отрицательные тесты для @, похожего домена, loopback, IPv6, приватного IP, нестандартного порта, пустого host и невалидного URL.
  5. Определите поведение redirect. По умолчанию отключите его; при необходимости проверяйте каждый новый адрес и ограничьте число переходов.
  6. Сверьте все адреса A и AAAA с сетевой политикой и проверьте фактические правила egress. Не подменяйте этот шаг строковым сравнением hostname.
  7. Задайте timeout, общий deadline, максимальный размер тела и лимит параллельных операций. Проверьте, что превышение каждого лимита останавливает чтение.
  8. Логируйте безопасную причину отказа, hostname или хэш операции и correlation id. Не записывайте credentials, query с секретами и полный URL без очистки.
  9. Покажите тестом, что запрещённый адрес не дошёл до сетевого клиента. Для разрешённого адреса отдельно проверьте статус, размер, формат ответа и обработку ошибки.
\n

Ограничения и критерий готовности

\n

Учебный валидатор не знает DNS, proxy, балансировщик, сетевые ACL и поведение конкретной HTTP-библиотеки. Allowlist домена не доказывает отсутствие DNS rebinding. Запрет redirect не решает проблему доступа к разрешённому, но скомпрометированному host. Лимит ответа не заменяет авторизацию и проверку формата. Эти ограничения нужно оставить в техническом контракте, иначе зелёный unit-тест создаст ложную уверенность.

\n

Endpoint готов к проверке, когда запрещённый URL не вызывает сетевой клиент, redirect проходит отдельную политику, все DNS-адреса проходят сетевую проверку, а timeout и размер тела прерывают операцию. Тестовый набор должен показать причины отказа для схемы, credentials, host, порта, IP и redirect. Если команда не может предъявить такой отрицательный результат, защита ещё не доказана.

\n

Проверяемые источники

" + "contentHtml": "

Сервис принимает URL картинки и скачивает её на сервере. Пользователь вводит https://cdn.example.test/avatar.jpg, но тот же endpoint может получить https://cdn.example.test@127.0.0.1/admin или адрес внутреннего metadata-сервиса. Если проверка ищет только подстроку https://cdn.example.test, сервер сам отправляет запрос не туда. Цена ошибки — чтение внутреннего ответа, обращение к административному интерфейсу и утечка данных через внешний ответ или журнал.

\n

Тезис простой: SSRF нужно останавливать до сетевого вызова и проверять разобранные поля URL, а не похожесть исходной строки. После этого нужны отдельные ограничения redirect, DNS, IP, порта, времени и размера ответа. Учебный код ниже возвращает решение политики, но не выполняет запрос и не доказывает безопасность конкретной сети.

\n

Как возникает ошибка

\n

URL содержит схему, authority, имя пользователя и пароль, hostname, порт, путь и query. Эти части имеют разную роль. Строка до символа @ может выглядеть как доверенное имя, но фактический host находится после него. В адресе https://cdn.example.test@127.0.0.1/file значение url.hostname — 127.0.0.1. Сравнение исходной строки не отвечает на вопрос, куда подключится клиент.

\n

Первый слой принимает только нужную схему. Для загрузки изображения это обычно https:. Второй слой запрещает username и password. Третий слой сравнивает нормализованный hostname с точным allowlist. Четвёртый слой решает, какие порты и пути разрешены. Только после этих проверок приложение может передать адрес сетевому клиенту.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
«Доверенный» URL обращается к loopbackПроверяли строковый префикс или часть до @Распарсить URL и вывести только hostnameЗапретить credentials и сравнивать фактический host
Проходит похожий доменИспользовали endsWith без границы имениПроверить evil-example.test и sub.example.testРазрешать точное имя или явный суффикс .example.test
Запрос уходит на другой адрес после 302Клиент автоматически следует redirectПерехватить заголовок LocationЗапретить redirect или повторить политику для каждого нового URL
Имя разрешено, IP закрытыйПроверен hostname, но не результат DNSПроверить A и AAAA и диапазоны адресовСверить адреса с политикой и контролировать egress
Разрешённый ответ занимает памятьЕсть allowlist, но нет лимита телаПроверить Content-Length и поток чтенияОстановить чтение после заданного размера и ограничить timeout
\n
\"Проверка
Каждое решение принимается до вызова сети. Отказ возвращает причину, но не передаёт непроверенный адрес следующему слою.
\n

Allowlist должен описывать ресурс

\n

Точный allowlist проще проверить, чем набор отрицательных исключений. Для одного CDN можно разрешить только cdn.example.test. Если нужны поддомены, правило должно различать границу: имя равно example.test или заканчивается на .example.test. Условие hostname.endsWith('example.test') пропустит evil-example.test. Оно также не отвечает, разрешён ли вложенный сервис и кто владеет его DNS-записью.

\n

Не принимайте список разрешённых доменов из запроса. Конфигурация принадлежит серверу и меняется через контролируемую поставку. Сравнивайте hostname после разбора стандартным URL-парсером. Не подставляйте вручную протокол, не вырезайте query регулярным выражением и не используйте отображаемое пользователю значение как доказательство адреса.

\n

Порт нужно ограничить отдельно. Явный :8443 — это не тот же сетевой контракт, что порт 443. Если приложение разрешает нестандартный порт, перечислите его для конкретного hostname. Запретите пустой или неожиданный порт, если клиент или прокси трактует его по-разному. Путь тоже может быть частью политики: CDN может разрешать только каталог изображений, а не весь host.

\n

Учебная проверка без запроса

\n

Функция принимает строку и массив имён. Она возвращает { allowed, reason, href }. Функция не вызывает fetch, не разрешает DNS и не проверяет корпоративный proxy. Поэтому её можно использовать только как маленький учебный пример для проверки порядка решений. Доменные имена в примерах вымышлены и не подтверждают наличие реального сервиса.

\n
function validateRemoteUrl(value, allowedHosts) {\n  let url;\n  try {\n    url = new URL(value);\n  } catch {\n    return { allowed: false, reason: 'invalid-url' };\n  }\n\n  if (url.protocol !== 'https:') {\n    return { allowed: false, reason: 'scheme' };\n  }\n  if (url.username || url.password) {\n    return { allowed: false, reason: 'credentials' };\n  }\n  if (url.port && url.port !== '443') {\n    return { allowed: false, reason: 'port' };\n  }\n  if (!allowedHosts.includes(url.hostname)) {\n    return { allowed: false, reason: 'host' };\n  }\n\n  return { allowed: true, reason: 'allowlist', href: url.href };\n}\n\nvalidateRemoteUrl(\n  'https://cdn.example.test/file.jpg',\n  ['cdn.example.test'],\n);\n// { allowed: true, reason: 'allowlist', href: ... }\n\nvalidateRemoteUrl(\n  'https://cdn.example.test@127.0.0.1/file.jpg',\n  ['cdn.example.test'],\n);\n// { allowed: false, reason: 'credentials' }
\n
const checks = [\n  ['https://cdn.example.test/file.jpg', true, 'allowlist'],\n  ['https://cdn.example.test@127.0.0.1/file.jpg', false, 'credentials'],\n  ['https://127.0.0.1/file.jpg', false, 'host'],\n];\n\nfor (const [input, allowed, reason] of checks) {\n  const result = validateRemoteUrl(input, ['cdn.example.test']);\n  if (result.allowed !== allowed || result.reason !== reason) {\n    throw new Error('Unexpected policy result for ' + input);\n  }\n}\n\nconsole.log('URL policy checks: PASS');
\n

Тестовый прогон проверяет три решения без сети: разрешённый host, credentials перед loopback и прямой loopback. Он не заменяет отдельные тесты DNS, IP, redirect и сетевого egress.

\n

Первый пример проходит allowlist. Во втором функция останавливается на credentials. Адрес https://127.0.0.1/file.jpg остановится на hostname. Это отрицательный путь: приложение не должно сначала выполнить запрос, а потом решить, был ли адрес допустим. Не включайте полный входной URL в лог отказа. В нём могут быть пароль, token или query с персональными данными.

\n

Редирект меняет цель

\n

Даже разрешённый CDN может ответить 301 или 302 с новым Location. После redirect исходный allowlist уже не описывает конечную цель. Надёжный вариант для простого endpoint — отключить автоматическое следование и вернуть отказ с классом redirect-not-allowed. Если перенаправление нужно по требованиям продукта, каждый новый URL должен пройти ту же проверку схемы, credentials, host и порта. Ограничьте число переходов.

\n

Проверяйте redirect до чтения тела ответа. Не разрешайте схему, отличную от исходной, если это не входит в явную политику. Не считайте относительный Location безопасным автоматически: его нужно разрешить относительно уже проверенного URL и снова проверить результат. Учебная функция выше redirect не обрабатывает. Это осознанная граница, а не пропущенная ветка.

\n

DNS и сетевой слой

\n

Проверка имени не доказывает, что соединение пойдёт к безопасному IP. DNS может вернуть несколько адресов. Запись может измениться между проверкой и подключением. Возможны IPv4, IPv6, loopback, link-local и приватные диапазоны. Политика должна решить, какие адреса допустимы, и применить это решение ко всем результатам A и AAAA, если модель угроз требует контроля каждого адреса.

\n

В чувствительной сети приложение и egress-шлюз должны дополнять друг друга. Приложение проверяет контракт endpoint. Сетевой слой запрещает выход к metadata, loopback и приватным сегментам, если они не нужны. Ни один слой не должен молча считать другой слой достаточным. Если библиотека клиента кеширует DNS или сама разрешает redirect, это нужно проверить в её документации и тесте.

\n

DNS rebinding и proxy могут изменить момент, в который адрес превращается в соединение. Не обещайте защиту одной функцией validateRemoteUrl. Для конкретного runtime нужен способ связать проверенный адрес с фактическим соединением или вынести запрос в изолированный egress-сервис с собственной политикой.

\n

Ограничения сетевого вызова

\n

Allowlist не ограничивает объём ответа. Сервер может вернуть большой файл, бесконечный поток или медленное тело. Задайте общий deadline, timeout установления соединения, максимальный размер ответа и ограничение числа одновременных загрузок. Проверяйте размер по заголовку, но не доверяйте ему как единственному барьеру: поток нужно прекращать при достижении лимита.

\n

Не принимайте ответ только потому, что его Content-Type похож на изображение. Сначала ограничьте размер и поток, затем проверяйте формат отдельной библиотекой и сохраняйте результат вне webroot по серверному имени. Это уже другая граница системы, но SSRF endpoint часто совмещает загрузку, декодирование и публикацию. Ошибка на одном шаге не должна превращать следующий в обход.

\n

Порядок внедрения

\n
  1. Найдите каждый endpoint, который получает URL или косвенно строит его из пользовательского ввода. Запишите цель запроса и побочный эффект.
  2. Опишите политику в конфигурации: схемы, точные hostname, допустимые порты, пути, redirect, размер и deadline.
  3. Разберите URL стандартным парсером до любого DNS или HTTP-вызова. Отдельно запретите username, password, неожиданные схемы и некорректные порты.
  4. Добавьте отрицательные тесты для @, похожего домена, loopback, IPv6, приватного IP, нестандартного порта, пустого host и невалидного URL.
  5. Определите поведение redirect. По умолчанию отключите его; при необходимости проверяйте каждый новый адрес и ограничьте число переходов.
  6. Сверьте все адреса A и AAAA с сетевой политикой и проверьте фактические правила egress. Не подменяйте этот шаг строковым сравнением hostname.
  7. Задайте timeout, общий deadline, максимальный размер тела и лимит параллельных операций. Проверьте, что превышение каждого лимита останавливает чтение.
  8. Логируйте безопасную причину отказа, hostname или хэш операции и correlation id. Не записывайте credentials, query с секретами и полный URL без очистки.
  9. Покажите тестом, что запрещённый адрес не дошёл до сетевого клиента. Для разрешённого адреса отдельно проверьте статус, размер, формат ответа и обработку ошибки.
\n

Ограничения и критерий готовности

\n

Учебный валидатор не знает DNS, proxy, балансировщик, сетевые ACL и поведение конкретной HTTP-библиотеки. Он также не ограничивает путь запроса, размер тела или число редиректов. Allowlist домена не доказывает отсутствие DNS rebinding. Запрет redirect не решает проблему доступа к разрешённому, но скомпрометированному host. Лимит ответа не заменяет авторизацию и проверку формата. Эти ограничения нужно оставить в техническом контракте, иначе зелёный unit-тест создаст ложную уверенность.

\n

Endpoint готов к проверке, когда запрещённый URL не вызывает сетевой клиент, redirect проходит отдельную политику, все DNS-адреса проходят сетевую проверку, а timeout и размер тела прерывают операцию. Тестовый набор должен показать причины отказа для схемы, credentials, host, порта, IP и redirect. Если команда не может предъявить такой отрицательный результат, защита ещё не доказана.

\n

Проверяемые источники

" } diff --git a/editorial/agent-rewrites/016.json b/editorial/agent-rewrites/016.json index 2b3bb04..9bb5b94 100644 --- a/editorial/agent-rewrites/016.json +++ b/editorial/agent-rewrites/016.json @@ -3,5 +3,5 @@ "slug": "editorial-2027-07-field-reliability-capstone", "title": "Когда retry превращается в аварию: как связать попытку, deadline и результат", "excerpt": "Разбираем лавину повторных запросов при сбое зависимости: какие поля сохранить, когда остановиться и почему timeout записи нельзя считать доказательством неуспеха.", - "contentHtml": "

Сервис вызывает зависимость, получает timeout и немедленно повторяет запрос. Зависимость ещё не восстановилась. Каждый экземпляр клиента добавляет новый запрос, очередь растёт, а исходная причина скрывается за потоком одинаковых ошибок. Пользователь ждёт дольше. Команда видит только «request failed» и не знает, сколько раз действие уже выполнялось.

\n

Цена ошибки зависит от операции. Для чтения повтор может лишь увеличить нагрузку. Для записи он может создать второй заказ, повторно списать деньги или отправить уведомление. Хуже всего timeout: клиент не знает, принял ли сервер запрос. Ответ мог потеряться после успешной записи.

\n

Тезис простой: retry нельзя проектировать как цикл вокруг HTTP-вызова. Клиент должен принимать решение по четырём данным — операции, попытке, оставшемуся времени и причине отказа. Если хотя бы одно поле отсутствует, автоматическое повторение становится догадкой.

\n

Механизм отказа

\n

Одна пользовательская операция может породить несколько сетевых попыток. У операции есть устойчивый operationId. У каждой попытки есть номер, время начала, deadline и результат. Эти сущности нельзя смешивать. Если записать только финальное «503», расследование потеряет порядок событий.

\n

Общий deadline ограничивает всю операцию. Локальный timeout ограничивает отдельный вызов. Три вызова по 400 миллисекунд не должны превращаться в 1,2 секунды, если пользовательский сценарий допускает только 700 миллисекунд. Перед каждой попыткой клиент вычисляет остаток бюджета. Когда бюджет исчерпан, он возвращает отдельное состояние deadline_exceeded.

\n

Причина и действие тоже различаются. 503, 429, timeout, ошибка DNS и отмена запроса — причины. retry, return, fail и cancel — действия. Раздельные поля позволяют увидеть, что именно создало нагрузку. Свободная фраза в логе такой подсчёт ломает.

\n
Минимальный контракт события попытки
ПолеПримерЗачем
operationIdop-42Связать попытки одной операции
attempt2Увидеть порядок и число вызовов
methodGETПроверить семантику повтора
status или errorClass503, timeoutОтделить ответ сервера от исключения
remainingMs180Понять, сколько времени оставалось
decisionretryЗафиксировать решение клиента
\n
\"Цикл
Каждая попытка сначала оставляет событие, затем проходит проверку времени и семантики операции. Неизвестный результат записи ведёт к проверке состояния, а не к слепому повтору.
\n

Что именно можно повторять

\n

HTTP-метод даёт полезную подсказку, но не заменяет контракт доменной операции. Идемпотентный запрос при повторе имеет тот же ожидаемый эффект, даже если отдельные ответы отличаются. Это не означает, что любой endpoint с таким методом безопасен в любой реализации. Серверный обработчик и его побочные эффекты остаются частью проверки.

\n

GET для чтения обычно можно повторить после временного 503, если клиент соблюдает общий лимит и не игнорирует Retry-After. PUT может быть безопасен, когда ключ ресурса задаёт всё состояние операции. DELETE требует правила для повторного 404. POST нельзя повторять по умолчанию. Для создания нужен подтверждённый механизм идемпотентности: ключ операции, срок его действия и сохранённый результат.

\n

Учебный пример ниже намеренно консервативен. Он повторяет только GET со статусом 503. Массив ответов заменяет сеть, поэтому код не доказывает поведение конкретной библиотеки и не описывает production-систему.

\n
function runBoundedRetries({ operationId, method, responses, maxAttempts = 3, deadlineMs = 400 }) {\n  const events = [];\n\n  for (let i = 0; i < Math.min(maxAttempts, responses.length); i += 1) {\n    const result = responses[i];\n    const remainingMs = Math.max(0, deadlineMs - i * 120);\n    const retryable = method === 'GET' && result.status === 503;\n    const decision = remainingMs === 0 ? 'fail' : retryable ? 'retry' : 'return';\n\n    events.push({\n      operationId,\n      attempt: i + 1,\n      method,\n      status: result.status,\n      remainingMs,\n      decision\n    });\n\n    if (decision !== 'retry') {\n      return { result: decision === 'fail' ? { status: 'deadline_exceeded' } : result, events };\n    }\n  }\n\n  return { result: { status: 'deadline_exceeded' }, events };\n}\n\nrunBoundedRetries({\n  operationId: 'op-42',\n  method: 'GET',\n  responses: [{ status: 503 }, { status: 503 }, { status: 200 }]\n});
\n

В этом учебном наборе клиент создаёт три события и возвращает 200. Если заменить метод на POST, первый 503 получит решение return. Такой результат не означает, что любой POST надо немедленно завершать. Он показывает отрицательный путь: без доказанной идемпотентности повтор запрещён.

\n

В настоящем клиенте есть ещё одна проверка. Если ответ потерялся после записи, новый POST не должен быть способом «узнать, получилось ли». Клиент сохраняет тот же ключ операции и запрашивает состояние отдельным endpoint либо передаёт запрос на ручное разбирательство. Если сервер не умеет ни дедупликацию, ни проверку состояния, политика должна явно возвращать неопределённый результат.

\n

Симптом → причина → проверка → действие

\n
Диагностическая матрица для повторов
СимптомПричинаПроверкаДействие
Резкий рост запросов после 503Нет общего deadline или backoffСравнить attempt и remainingMsОграничить бюджет, добавить задержку и jitter
Один пользователь получил две записиPOST повторили после timeoutСопоставить operationId на сервереОстановить retry, ввести ключ и запрос состояния
В логах только «failed»Причина и decision слитыНайти поля status/errorClass и decisionСделать перечисление причин и действий
Клиент ждёт дольше SLATimeout задан на попытку, не на операциюПроверить остаток времени перед каждым вызовомПередавать общий deadline вниз по стеку
После 429 нагрузка не падаетКлиент игнорирует ограничение сервераПроверить Retry-After и частоту попытокСнизить темп и завершать попытку по политике лимита
Нельзя связать клиентский и серверный следИдентификатор меняется при retryСопоставить operationId и requestIdСохранить идентификатор операции, а запросу дать номер попытки
\n

Как читать отрицательный путь

\n

Рассмотрим последовательность для чтения. Первая попытка получила 503 при остатке 380 миллисекунд. Клиент записал decision=retry, подождал ограниченный интервал и повторил запрос. Вторая попытка снова получила 503. Осталось 120 миллисекунд, поэтому третья попытка допустима только после оценки её минимального времени выполнения. Если бюджет мал, клиент завершает операцию с deadline_exceeded, даже если в массиве есть следующий ответ.

\n

Теперь рассмотрим запись. Сервер мог принять запрос, но соединение оборвалось до ответа. Клиент записал errorClass=timeout, decision=check_state и сохранил operation key. Он не создаёт новую запись. Это медленнее, чем слепой retry, но цена неизвестного результата ниже цены дублирования побочного эффекта.

\n

Для логов достаточно безопасного endpoint без query-секретов, метода, статуса, класса ошибки, номера попытки, оставшегося времени и решения. Не записывайте тело ответа, cookie, токены, номера карт и произвольные пользовательские строки без отдельной причины. Поле наблюдаемости не должно становиться новым каналом утечки.

\n

Порядок внедрения

\n
  1. Назвать доменное действие и его побочный эффект. Не ограничиваться HTTP-методом.
  2. Зафиксировать operationId и правило его жизненного цикла. Один retry не должен создавать новый идентификатор операции.
  3. Разделить timeout отдельного вызова и общий deadline операции.
  4. Составить явный список повторяемых причин и методов. Для каждой пары указать лимит попыток и действие при исчерпании времени.
  5. Добавить событие попытки с attempt, status или errorClass, remainingMs и decision.
  6. Для записи проверить потерю ответа: повторить тот же ключ, а затем запросить состояние.
  7. Удалить секреты и персональные данные из endpoint, заголовков, тела и идентификаторов до отправки события.
  8. Проверить четыре сценария: успешный первый вызов, GET/503, GET/timeout и POST/timeout.
  9. Считать распределение попыток и долю завершений по deadline. Не менять политику из-за одной шумной записи.
\n

Ограничения

\n

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

\n

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

\n

Проверяемый критерий готовности

\n

Изменение готово, если тестовый набор воспроизводит все четыре сценария и для каждого проверяет не только итог, но и последовательность событий. Для GET после двух временных отказов видны попытки 1 и 2 с decision=retry, а затем успешный возврат либо честное завершение по deadline. Для POST после timeout нет второго побочного вызова: клиент сохраняет тот же operation key и переходит к проверке состояния. В каждом событии есть причина, действие и оставшееся время. Секреты отсутствуют.

\n

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

\n

Проверяемые источники

" + "contentHtml": "

Сервис вызывает зависимость, получает timeout и немедленно повторяет запрос. Зависимость ещё не восстановилась. Каждый экземпляр клиента добавляет новый запрос, очередь растёт, а исходная причина скрывается за потоком одинаковых ошибок. Пользователь ждёт дольше. Команда видит только «request failed» и не знает, сколько раз действие уже выполнялось.

\n

Цена ошибки зависит от операции. Для чтения повтор может лишь увеличить нагрузку. Для записи он может создать второй заказ, повторно списать деньги или отправить уведомление. Хуже всего timeout: клиент не знает, принял ли сервер запрос. Ответ мог потеряться после успешной записи.

\n

Тезис простой: retry нельзя проектировать как цикл вокруг HTTP-вызова. Клиент должен принять решение по четырём данным — операции, попытке, оставшемуся времени и причине отказа. Если хотя бы одно поле отсутствует, автоматическое повторение становится догадкой.

\n

Механизм отказа

\n

Одна пользовательская операция может породить несколько сетевых попыток. У операции есть устойчивый operationId. У каждой попытки есть номер, время начала, deadline и результат. Эти сущности нельзя смешивать: requestId помогает найти один сетевой вызов, а operationId связывает весь пользовательский сценарий. Если записать только финальное «503», расследование потеряет порядок событий.

\n

Общий deadline ограничивает всю операцию. Локальный timeout ограничивает отдельный вызов. Три вызова по 400 миллисекунд не должны превращаться в 1,2 секунды, если пользовательский сценарий допускает только 700 миллисекунд. Перед каждой попыткой клиент вычисляет остаток бюджета. Когда бюджет исчерпан, он возвращает отдельное состояние deadline_exceeded; когда достигнут лимит попыток, это другое состояние.

\n

Причина и действие тоже различаются. 503, 429, timeout, ошибка DNS и отмена запроса — причины. retry, return, check_state, attempts_exhausted и cancel — действия. Раздельные поля позволяют увидеть, что именно создало нагрузку. Свободная фраза в логе такой подсчёт ломает.

\n
Минимальный контракт события попытки
ПолеПримерЗачем
operationIdop-42Связать попытки одной операции
requestIdreq-02Найти одну сетевую попытку
attempt2Увидеть порядок и число вызовов
methodGETПроверить семантику повтора
status или errorClass503, timeoutОтделить ответ сервера от исключения
remainingMs180Понять, сколько времени оставалось
decisionretryЗафиксировать решение клиента
\n
\"Цикл
Каждая попытка сначала оставляет событие, затем проходит проверку времени и семантики операции. Неизвестный результат записи ведёт к проверке состояния, а не к слепому повтору.
\n

Что именно можно повторять

\n

HTTP-метод даёт полезную подсказку, но не заменяет контракт доменной операции. Идемпотентный запрос при повторе имеет тот же ожидаемый эффект, даже если отдельные ответы отличаются. Это не означает, что любой endpoint с таким методом безопасен в любой реализации: серверный обработчик и его побочные эффекты остаются частью проверки.

\n

GET для чтения обычно можно повторить после временного 503, если клиент соблюдает общий лимит и учитывает Retry-After. PUT может быть безопасен, когда ключ ресурса задаёт всё состояние операции. DELETE требует правила для повторного 404. POST нельзя повторять по умолчанию. Для создания нужен подтверждённый механизм идемпотентности: ключ операции, срок его действия, проверка параметров и сохранённый результат.

\n

Статус сам по себе тоже не даёт разрешения на retry. 503 обычно означает временную недоступность, но ответ посредника мог появиться после того, как upstream уже применил запись. 429 требует учесть ограничение сервера, а ошибка DNS, отмена пользователем и ошибка валидации не должны попадать в один список с временным отказом.

\n

Воспроизводимый пример

\n

Учебный пример ниже намеренно консервативен. Он не обращается в сеть, а получает заранее заданный массив ответов. Это позволяет воспроизвести решение клиента и отдельно увидеть разницу между последней попыткой и исчерпанным deadline. Пример повторяет только GET со статусом 503; он не доказывает поведение конкретной HTTP-библиотеки.

\n
function runBoundedRetries({ operationId, method, responses, maxAttempts = 3, deadlineMs = 400 }) {\n  const events = [];\n  const attemptLimit = Math.min(maxAttempts, responses.length);\n\n  for (const [index, result] of responses.slice(0, attemptLimit).entries()) {\n    const attempt = index + 1;\n    const remainingMs = Math.max(0, deadlineMs - index * 120);\n    const isLastAttempt = attempt === attemptLimit;\n    const canRetry = method === 'GET' && result.status === 503 && remainingMs > 0 && !isLastAttempt;\n    const decision = remainingMs === 0\n      ? 'deadline_exceeded'\n      : canRetry\n        ? 'retry'\n        : method === 'POST' && result.status === 'timeout'\n          ? 'check_state'\n          : isLastAttempt && result.status === 503\n            ? 'attempts_exhausted'\n            : 'return';\n\n    events.push({ operationId, attempt, method, status: result.status, remainingMs, decision });\n\n    if (decision !== 'retry') {\n      const finalResult = decision === 'deadline_exceeded'\n        ? { status: 'deadline_exceeded' }\n        : decision === 'attempts_exhausted'\n          ? { status: 'attempts_exhausted' }\n          : decision === 'check_state'\n            ? { status: 'unknown_result' }\n            : result;\n      return { result: finalResult, events };\n    }\n  }\n\n  return { result: { status: 'attempts_exhausted' }, events };\n}\n\nconst example = runBoundedRetries({\n  operationId: 'op-42',\n  method: 'GET',\n  responses: [{ status: 503 }, { status: 503 }, { status: 200 }]\n});\n\nconsole.assert(example.result.status === 200);\nconsole.assert(example.events.map((event) => event.decision).join(',') === 'retry,retry,return');
\n

В этом наборе клиент создаёт три события и возвращает 200. Последняя попытка не получает решение retry, потому что дальше идти нельзя. Если заменить третий ответ на 503, результатом станет attempts_exhausted, а не ошибочно названный deadline_exceeded. Если заменить метод на POST и ответ на timeout, клиент перейдёт в unknown_result и не создаст второй вызов.

\n

Проверки console.assert фиксируют итог и порядок решений. Они не заменяют тест реального клиента: в production нужно измерять монотонное время, обрабатывать сетевые исключения, учитывать Retry-After, добавлять backoff с jitter и передавать отдельный requestId для каждой попытки.

\n

Симптом → причина → проверка → действие

\n
Диагностическая матрица для повторов
СимптомПричинаПроверкаДействие
Резкий рост запросов после 503Нет общего deadline или backoffСравнить attempt и remainingMsОграничить бюджет, добавить задержку и jitter
Один пользователь получил две записиPOST повторили после timeoutСопоставить operationId на сервереОстановить retry, ввести ключ и запрос состояния
В логах только «failed»Причина и decision слитыНайти status/errorClass и decisionСделать перечисление причин и действий
Клиент ждёт дольше SLATimeout задан на попытку, не на операциюПроверить остаток времени перед вызовомПередавать общий deadline вниз по стеку
После 429 нагрузка не падаетКлиент игнорирует ограничение сервераПроверить Retry-After и частоту попытокСнизить темп и завершать попытку по политике лимита
Нельзя связать клиентский и серверный следИдентификатор меняется при retryСопоставить operationId и requestIdСохранить идентификатор операции, запросу дать новый номер
\n

Неизвестный результат записи

\n

Если ответ потерялся после записи, новый POST не должен быть способом «узнать, получилось ли». Клиент сохраняет тот же ключ операции и запрашивает состояние отдельным endpoint либо передаёт запрос на ручное разбирательство. Если сервер не умеет ни дедупликацию, ни проверку состояния, политика должна явно возвращать неопределённый результат.

\n

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

\n

Логи помогают расследованию, но не делают повтор безопасным. Записывайте метод, endpoint без секретных параметров, обезличенный ключ операции, requestId, номер попытки, статус, класс ошибки, остаток времени и решение. Не записывайте тело ответа, cookie, токены, номера карт и произвольные пользовательские строки без отдельной причины.

\n

Порядок внедрения

\n
  1. Назвать доменное действие и его побочный эффект. Не ограничиваться HTTP-методом.
  2. Зафиксировать operationId и правило его жизненного цикла. Один retry не должен создавать новый идентификатор операции.
  3. Разделить timeout отдельного вызова и общий deadline операции.
  4. Составить явный список повторяемых причин и методов. Для каждой пары указать лимит попыток, backoff и действие при исчерпании времени.
  5. Добавить событие попытки с requestId, attempt, status или errorClass, remainingMs и decision.
  6. Для записи проверить потерю ответа: повторить тот же ключ, а затем запросить состояние.
  7. Удалить секреты и персональные данные из endpoint, заголовков, тела и идентификаторов до отправки события.
  8. Проверить четыре сценария: успешный первый вызов, GET/503, GET/timeout и POST/timeout.
  9. Считать распределение попыток и долю завершений по deadline. Не менять политику из-за одной шумной записи.
\n

Ограничения

\n

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

\n

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

\n

Учебная функция не моделирует два процесса, атомарность базы, частичную запись, истечение TTL ключа или повтор после восстановления. Поэтому она годится для проверки ветвления и названий состояний, но не для обещаний о доступности или времени ответа. Производственные числа получают из наблюдений конкретной системы.

\n

Проверяемый критерий готовности

\n

Изменение готово, если тестовый набор воспроизводит все четыре сценария и для каждого проверяет не только итог, но и последовательность событий. Для GET после временных отказов видны попытки с decision=retry, а затем успешный возврат, attempts_exhausted или честное завершение по deadline. Для POST после timeout нет второго побочного вызова: клиент сохраняет тот же operation key и переходит к проверке состояния. В каждом событии есть причина, действие и оставшееся время. Секреты отсутствуют.

\n

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

\n

Проверяемые источники

" }