Skip to content

Compare versions

From:To:
+14
**Equiply, 1 сентября 2026 года. Разбор про себя** — по правилу «мы попадаем в разборы наравне».

Equiply, 1 сентября 2026 года. Разбор про себя — по правилу «мы попадаем в разборы наравне».

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

**8 июня 2026 — рождается замок.**

8 июня 2026 — рождается замок.

Первый коммит проекта, 9434579, называется длинно и по-хозяйски: AEP — портал приводчиков (оборудование, склад, руководства, наряды, ИИ-чат).

Внутри — функция, которая на каждом запросе решает, кто перед нами:

ts
1if (row.user.deletedAt || row.user.status === 'blocked') return null

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

**23 августа, 23:32 — строится вторая дверь.**

23 августа, 23:32 — строится вторая дверь.

Приезжает коммит c1ab6db: feat(mcp): персональные токены доступа в профиле пользователя.

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

Это не короткое сообщение на строку. Оно на пять абзацев, и почти все — про безопасность:

Это не короткое сообщение на строку. Оно на пять абзацев, и почти все — про безопасность:

Токен — aep_ плюс 32 случайных байта; в базе только SHA-256 от него, поэтому утечка таблицы доступа не даёт, а пользователю он показывается один раз. Хеш без соли и не bcrypt намеренно: это не пароль, а 256 бит энтропии — перебирать нечего, а быстрый хеш нужен для поиска индексом. Отзыв мягкий: строка остаётся, чтобы не терять историю «кто ходил».

Продумано хранение. Продуман отзыв. Продуман журнал. Продумано даже то, чего намеренно не делают и почему.

А проверка на предъявлении получилась такая:

А проверка на предъявлении получилась такая:

ts
1if (!row || row.revokedAt) return null

Токен не отозван — проходи.

Про то, жив ли ещё человек, за которым этот токен закреплён, не сказано ни слова. Ни в коде, ни в сообщении.

**1 сентября — первый проход ревью.**

1 сентября — первый проход ревью.

Одна линза за проход, свой вопрос, находка доказывается прогоном, а не чтением. Линза первая — доступ и права. Обход начинается с машинных поверхностей: там цена ошибки выше.

Проба дёргает настоящий verifyToken, а не свою копию его логики:

code
1активный → ДОСТУП ЕСТЬ
2заблокирован → ДОСТУП ЕСТЬ
3мягко удалён → ДОСТУП ЕСТЬ
Администратор нажимает «Заблокировать». Человек перестаёт входить на сайт в ту же секунду — замок с 8 июня работает.

Администратор нажимает «Заблокировать». Человек перестаёт входить на сайт в ту же секунду — замок с 8 июня работает.

Его ИИ-клиент в это время продолжает выкачивать реестр оборудования, все 2624 позиции, склад, движения ТМЦ и ежедневник. Ровно так же, как вчера. И будет завтра.

Отозвать нечего: блокировка токены не трогает, удаление тоже. А посмотреть, какие токены выписаны у сотрудника, администратор не может — чужие токены в интерфейсе не показываются.

Между постройкой второй двери и находкой — девять дней.

**Дверь, которую никто не открывал.**

Дверь, которую никто не открывал.

Дальше та часть, без которой разбор был бы враньём.

sql
1select count(*) from mcp_tokens;
2-- 0

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

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

**Починка.**

Починка.

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

Вместо этого условие переехало туда, где ему и место, — в саму проверку предъявления:

ts
1if (row.userDeletedAt || row.userStatus === 'blocked') return null

Та же строка, что на 8 июня. Теперь она есть у обеих дверей.

**Почему это разбор про машину.**

Почему это разбор про машину.

Оба коммита — и тот, что построил дверь, и тот, что навесил замок, — подписаны Co-Authored-By: Claude Opus 5.

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

И он был не поверхностным. Ошибка не в том, что не подумали. Думали много и вслух.

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

И последнее.Added

И последнее.

Тот самый коммит от 23 августа, третьим абзацем, объясняет, зачем вообще заводились личные токены:

…а уход сотрудника не требует менять ключ всем сразу.

Уход сотрудника был единственным сценарием, ради которого всё это писалось. Он же оказался единственным, который не работал.

Пересчитать двери к своим даннымMoved

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

grep -rn "getCurrentUser\|verifyToken\|Bearer\|shareToken" src/ | cut -d: -f1 | sort -u
Заменить действие при блокировке на проверку при предъявленииMoved

Не «при блокировке отозвать всё выданное», а «на входе спросить, жив ли владелец».

Не принимать длину объяснения за полноту покрытияMoved

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

Доказать прогоном, а не чтениемMoved

Написать пробу, которая дёргает НАСТОЯЩУЮ функцию проверки прав и прогоняет её по состояниям владельца: активен, заблокирован, удалён.