Роли и владение: когда чужая строка отвечает 404, а когда 403

доступ≈ 19 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Личность у нас уже есть: страж на каждом запросе достал её из токена и перепроверил в таблице. Гостю он ответил 401, и на этом его работа кончилась. Остались два вопроса, и они разные. Что этому человеку можно вообще — вопрос про роль: удалять чужое, смотреть журнал, менять чужие роли. Над чем именно ему можно — вопрос про владение, и ответ на него не лежит ни в токене, ни в таблице пользователей: он зависит от пары «человек и строка». Половина ответа уже написана: одна дверь к строкам решает, какие строки этому читателю видно. Вторая половина — про запись, и условием выборки её не закрыть. Осталось решить, чем отказывать, — и тут выяснится, что даже код ответа это данные: 403 на чужой черновик рассказывает, что такой черновик есть, а перебором номеров из этого складывается карта.

Разделы темы:·

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

Владение так не устроено. «Можно ли Бобу править эту заметку» — вопрос про пару: про Боба и про строку номер такой-то. А строки у стража нет. В нашем каркасе страж получает ctx с запросом, параметрами адреса и именами контроллера с обработчиком — номер заметки в нём есть, самой заметки нет. Страж мог бы сходить в базу сам, но тогда строку прочитают дважды: один раз он, второй раз обработчик. Поэтому владение проверяют не в страже, а в сервисе — сразу после того, как строка прочитана.

И ещё одно правило, которое сейчас понадобится целиком: подробный отказ тому, кому нельзя, — это утечка. Там подробностью были поля входа, здесь ею окажется сам код ответа.

Ниже приложение с настоящим стражем роли на удалении: удалять разрешено только роли admin, и модератор для этого добавлен в ту же таблицу пользователей, которую спрашивает страж личности. А правка заголовка написана так, как её пишут в первый день: нашли строку по номеру, записали новый заголовок. Боб — обычный пользователь с честным токеном. Прогноз: какие из его запросов сервер выполнит над чужими заметками?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

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

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

Роль — свойство человека, владение — свойство пары «человек и строка». Первое лежит в токене, второе — только в самой строке.

Хорошо, проверку владения добавили. Чем отказывать? Первым в голову приходит 403: человек известен, заметка не его — «доступ закрыт». Ответ честный, вежливый и ровно поэтому опасный. 403 говорит не только «нельзя»: он говорит «есть что запрещать». Для одного номера это ничего не значит. Для всех номеров подряд это карта.

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

Ниже один и тот же сервис с двумя правилами про чужой черновик: в первом он отвечает 403, во втором — тем же, чем несуществующая заметка. Боб перебирает номера с первого по восьмой своим настоящим токеном; два номера в таблице освободились после удаления, два не выдавались никогда. Прогноз: сколько заметок Боб насчитает в каждом случае?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

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

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

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

В боевой базе учебника у заметок не счётчик, а uuid со значением по умолчанию из gen_random_uuid(): 122 случайных бита. Перебирать их бессмысленно — столько запросов не сделать никому. Значит ли это, что правило про 404 нужно только моделям с номерами подряд? Нет, и по трём причинам.

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

404 — это не ложь. Это ответ на вопрос «что у вас есть для меня», а не на вопрос «что у вас есть».

Соберём правило целиком. Чужой черновик отвечает как несуществующая заметка — это уже решено. А чужая опубликованная? 404 на неё был бы не защитой, а путаницей: человек видит заметку в ленте, жмёт «править» и получает «нет такой». Скрывать там нечего, а сбить с толку — можно. Значит, честный 403: «заметка есть, она не ваша».

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

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Разными кодами ответила одна строка — чужая опубликованная: читать можно, править нельзя. Расхождение законное: существование опубликованной заметки и так открыто — она лежит в общей ленте. Зато правка и удаление совпали на всех пяти строках, и это не совпадение, а требование: обе двери спрашивают одно и то же. Разойдутся — и разница снова превращается в тот самый бит, потому что спрашивающему довольно найти дверь, которая отвечает иначе.

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

СлучайОтветЧто узнал спрашивающий
Токена нет или он не годится401Что надо представиться
Роль не та403Что дело не в токене, а в правах
Чужая опубликованная заметка403Что заметка есть — но это и так открыто
Чужой черновик404Ничего
Номера нет в таблице404Ничего

Правка и удаление на одну и ту же строку обязаны отвечать одинаково. Разошлись — значит, проверок владения у вас две, а не одна.

Есть способ проверить владение, не читая строку: дописать владельца в условие самой правки — UPDATE notes SET title = $1 WHERE id = $2 AND author = $3. Приём знаком: так же поступают с версией строки, когда правка должна проверить, что строка не менялась. Ответом будет не строка, а число изменённых строк.

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

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ноль изменённых строк — снова несколько случаев в одном числе, и здесь их три: строки нет, строка чужая опубликованная, строка чужой черновик. Отвечать по такому числу можно только одинаково, и одинаковый ответ здесь — 404. Для черновика и для несуществующего номера это ровно то, что нужно; для чужой опубликованной — та самая путаница, от которой мы уходили. Значит, честный 403 покупается отдельным чтением.

Та же пара способов на настоящем Postgres — с настоящим rowCount и настоящим окном между двумя запросами.

прогон под базой PostgreSQL 18.4 · node 26.5.0 · 28 сентября 2026
Четыре случая правки в Postgres: сначала одним UPDATE с условием владения, потом чтением и правкой по отдельности
import pg from 'pg';

// адрес базы — первой строкой и без пароля: видно, куда ушли запросы
const url = new URL(process.env.DATABASE_URL);
url.password = '';
console.log('база:', url.href);

// два клиента — два соединения: второй понадобится, чтобы влезть между запросами первого
const api = new pg.Client({ connectionString: process.env.DATABASE_URL });
const other = new pg.Client({ connectionString: process.env.DATABASE_URL });
await api.connect();
await other.connect();

await api.query(`CREATE TABLE notes (
  id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  author text NOT NULL,
  title text NOT NULL,
  status text NOT NULL
)`);
// три заметки с известными номерами: две чужие, одна своя
await api.query(`INSERT INTO notes (author, title, status) VALUES
  ('ann', 'Хлеб и молоко', 'published'),
  ('ann', 'План побега', 'draft'),
  ('bob', 'Отчёт за март', 'published')`);

const VIEWER = 'bob';
let queries = 0;
const ask = (text, values) => {
  queries += 1;
  return api.query(text, values);
};

// Одним запросом: владение уехало в условие правки
async function renameByOne(id) {
  const result = await ask('UPDATE notes SET title = $1 WHERE id = $2 AND author = $3 RETURNING id', [
    'новый заголовок',
    id,
    VIEWER,
  ]);
  return { changed: result.rowCount, answer: result.rowCount === 0 ? '404 NOT_FOUND' : '200' };
}

// Двумя: сначала прочитать строку, потом решить, что ответить
async function renameByTwo(id, between) {
  const { rows } = await ask('SELECT author, status FROM notes WHERE id = $1', [id]);
  const row = rows[0];
  if (row === undefined) return { changed: 0, answer: '404 NOT_FOUND' };
  if (row.author !== VIEWER) {
    return { changed: 0, answer: row.status === 'draft' ? '404 NOT_FOUND' : '403 NOT_YOUR_RESOURCE' };
  }
  if (between !== undefined) await between();
  const result = await ask('UPDATE notes SET title = $1 WHERE id = $2', ['новый заголовок', id]);
  return { changed: result.rowCount, answer: '200' };
}

const cases = [
  ['своя опубликованная', 3],
  ['чужая опубликованная', 1],
  ['чужой черновик', 2],
  ['номер, которого нет', 9],
];
for (const [name, id] of cases) {
  const one = await renameByOne(id);
  const two = await renameByTwo(id);
  console.log(`${name} (${id}): одним запросом изменено ${one.changed} → ${one.answer}; двумя → ${two.answer}`);
}
console.log('запросов к базе всего:', queries);

// Окно между чтением и записью: пока сервис решал, заметку удалили из другого соединения
const stolen = await renameByTwo(3, async () => {
  await other.query('DELETE FROM notes WHERE id = 3');
  console.log('  между запросами: другое соединение удалило заметку 3');
});
console.log('своя заметка, удалённая в окне: изменено', stolen.changed, '→', stolen.answer);
const after = await renameByOne(3);
console.log('тот же случай одним запросом: изменено', after.changed, '→', after.answer);
console.log('строк в таблице осталось:', (await api.query('SELECT count(*)::int AS n FROM notes')).rows[0].n);

await api.end();
await other.end();
node own.mjs

В строках про четыре случая числа и коды у базы те же, что у модели; разошлись только слова — в прогоне отказ несёт доменный код, как в боевом API. И видно, чем оплачено различение случаев: до хвоста вывода ушло девять запросов на восемь попыток правки. У первого способа по одному на попытку, у второго — чтение, а если оно разрешило, ещё и запись. А хвост вывода — про то, чего у модели не было вовсе. Между чтением и правкой есть окно, и в прогоне в него влезло другое соединение и удалило заметку. Способ «двумя запросами» ответил 200, не изменив ни одной строки: он решил по снимку, которого к моменту записи уже не было. Способ «одним запросом» на том же случае ответил 404 — у него проверка и запись неразделимы.

Поэтому в боевом сервисе после записи строку перечитывают, и в комментарии сказано прямо: её отсутствие означало бы гонку удаления. А выбор между способами простой. Если 403 не нужен вовсе — все чужие строки скрыты одинаково, — довольно одного запроса, и окна у него нет. Если 403 нужен, читают строку ради ответа, а записывают тем же условием владения: чтение решает, что сказать, условие решает, что записать. Стартер учебника написан не так: его репозиторий (notes.repository.ts) пишет update и delete по одному id, без владельца в условии, — владение остаётся в прочитанной строке, а окно закрывает перечитывание: строки после записи нет, значит NOT_FOUND, а не 200, за которым ничего не записано.

Ноль изменённых строк — это не «нет такой». Это «ни одна строка не подошла под условие», а в условии было и владение.

У API учебника заметки принадлежат авторам, и проверка владения живёт в сервисе — там, где строка только что прочитана. Ниже выдержка из apps/api/src/notes/notes.service.ts: три метода из восьми, остальное убрано, комментарии дословны.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Тернарник в mustOwn — это ровно та развилка, которую мы собирали: черновик уходит в NOT_FOUND, опубликованная — в NOT_YOUR_RESOURCE. Комментарий над методом называет обе половины правила, а комментарий в byId — причину: по перебору идентификаторов читалась бы карта черновиков. И обратите внимание, что remove не проверяет ничего сам: вся проверка — в mustOwn, а та вместе с разрешением возвращает и строку, если обработчику она нужна. Так же устроен и update, которого в выдержке нет.

Куда именно превращаются эти два кода, разбиралось в главе про свой конвейер: таблица доменных кодов того же приложения переводит NOT_FOUND в NOT_FOUND tRPC, то есть в 404, а NOT_YOUR_RESOURCE — в FORBIDDEN, то есть в 403. Доменный код при этом едет и в message, потому что клиенты разбирают именно его: кодов у tRPC мало, а причин отказа много.

Роль в том же API проверяется отдельно от владения — звеном, которое можно повесить на процедуру или на весь роутер. Ниже оно целиком, убраны только импорты.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Звено тонкое: вся работа в authenticate — той самой общей функции, которая проверяет подпись, находит пользователя и сверяет роль из токена с ролью в базе; её выдержку и разбор даёт статья о звеньях. Дальше одно сравнение и FORBIDDEN с доменным словом WRONG_ROLE — не 401, потому что личность известна и новый токен не поможет.

Комментарий у этого звена стоит прочитать целиком: админских процедур в стартере нет, и объяснено, почему. Модерация ломала бы правило «править и удалять может только автор» — то есть роль admin в таком API нужна не для того, чтобы обходить владение, а для служебных ручек, где строк с авторами нет вовсе. Звено оставлено образцом, и его поведение закреплено тестом.

То же правило ждёт нас в главе про синхронизацию, и там оно опаснее: в протоколе части V идентификатор строки рождается на клиенте, значит клиент может прислать чужой. Владение в push и pull закрывает тот же угон, только ответом служит не код HTTP, а вердикт в пачке. А форма восстановления пароля доводит правило до предела: там одинаковыми обязаны быть и ответ, и время ответа, потому что скрывается не заметка, а сам факт регистрации.

Правило про 404 пишут не в каждом обработчике, а в одном месте, у которого есть имя. У автора это mustOwn.

  • Роль отвечает на вопрос «что этому человеку можно вообще» и живёт в токене; владение — свойство пары «человек и строка», и проверить его можно только после чтения строки. Поэтому роль спрашивает страж, а владение — сервис.
  • Отказ 403 подтверждает существование записи. Перебор номеров превращает эти подтверждения в карту: занятые номера, число скрытых записей, верхнюю границу счётчика. Поэтому чужой черновик отвечает так же, как несуществующая заметка.
  • Чужая опубликованная заметка — наоборот, 403: её существование и так открыто, а 404 сбил бы с толку того, кто видит её в ленте. Скрывают существование только того, чьё существование скрыто.
  • Ответы всех дверей, которые спрашивают владение, обязаны совпадать — и кодом, и текстом, и формой тела. Пока хоть одна отвечает иначе, перебор идёт через неё, поэтому проверка владения — одна функция на все пишущие обработчики.
  • Условие владения внутри правки (WHERE id = … AND author = …) атомарно и коротко, но отвечает одним числом на три разных случая: 403 покупается отдельным чтением, а между чтением и правкой появляется окно.
  • В боевом API это mustOwn с тернарником draft ? NOT_FOUND : NOT_YOUR_RESOURCE, таблица доменных кодов и отдельное звено для роли: FORBIDDEN с WRONG_ROLE, а не 401.

1/4Почему на чужую опубликованную заметку отвечают 403, а не 404, как на чужой черновик?

2/4Сервис отвечает 403 на чужой черновик и 404 на номер, которого нет. Что даст перебор номеров?

3/4Запрос UPDATE notes SET title = $1 WHERE id = $2 AND author = $3 изменил ноль строк. Что это значит?

4/4Чтение отвечает на чужой черновик 404, а удаление той же заметки — 403. Правило в чтении соблюдено. Почему это всё равно утечка?

Удаление написали отдельно

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

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Третья дверь отвечает иначе, чем две первые, и этого довольно: 403 подтверждает, что заметка 2 существует. Отдельная проверка в remove не хуже написана — она просто не знает про черновики.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

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

Полезно и то, что mustOwn возвращает строку: обработчику она всё равно нужна, и второе чтение той же строки не понадобится.

Автор приехал во входе

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

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Сравнение row.author !== input.author проверяет не право, а согласованность запроса: оба числа прислал тот, у кого мы спрашиваем разрешения. Подставьте под чужую заметку её настоящего автора — и проверка пройдёт. Личность в функцию приехала (viewer из токена), но в проверке не участвует.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Одна строка: сравнивать надо с тем, что сказал токен, а не с тем, что сказал запрос. Вывод после правки — 200, потом три 403 это не ваша заметка: ни одно содержимое входа больше не помогает. Это то же правило, что в части IV: личность берут из токена или сессии, а аргументы отвечают только на вопрос «над чем работаем».

Дальше правку стоит довести до правила этой главы: чужой черновик не должен отвечать 403.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь последняя строка вывода — 404 нет такой. А поле author после этого из входа убирают вовсе, а не просто обходят: пока оно в типе, кто-нибудь им ещё воспользуется, а валидатор границы — как раз то место, где лишнему полю не дают войти.

Код один, слова разные

Правило соблюдено: и на чужой черновик, и на несуществующий номер сервис отвечает 404. Запустите и посмотрите на две последние строки вывода.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Код совпал, а ответ — нет: текст рассказывает то самое, что код скрывал. Спрашивающему довольно читать не код, а тело ответа.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

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

А то, чем два ответа всё-таки отличаются, пишут в журнал сервера: там и «чужой черновик», и номер заметки, и кто спрашивал. Журнал читают свои, ответ читает кто угодно.