fix(v1): сделать read-only свойством doctor, а sentinel-ошибки — решением
Два свойства были описаны в документации, но не обеспечены кодом.
1. doctor «не изменяет диагностируемую систему».
Принудительный skipServiceStart закрывал ровно одну ИЗВЕСТНУЮ мутацию —
рестарт сервисов. Всё остальное в smoke держалось на том, что автор правки
выбрал правильный раннер: `test -s`, `grep -q`, `stat`, `sudo -u ... test`
и `nft -c` шли через мутирующий namespace, хотя ничего не меняют. Ожидание
между попытками выполнялось подпроцессом `sleep` через runMutatingHidden,
то есть пауза между двумя чтениями объявлялась изменением системы.
Следствие: настоящая мутация, случайно добавленная в smoke, ничем бы от них
не отличалась и была бы разрешена в doctor молча — а включить guard было
нельзя, он отказал бы на первой же читающей команде.
Команды классифицированы честно, `sleep` заменён таймером, и doctor целиком
выполняется под тем же read-only guard, что и PHASE 0 установки. Guard
снимается в finally. Диагностика при этом не сузилась: слушатели, healthz,
права, machine auth, trafficStats, версия бинаря, семантика конфига и
синтаксис nft проверяются полностью.
2. reset-admin различает «администратора нет» и «база не ответила».
Слой данных специально возвращает разные sentinel'ы, но команда склеивала их
обычным `if err != nil { создать } else { обновить }`. Опасен здесь не
только нарушенный смысл: при транзиентном отказе чтения («database is
locked») ветка создания отрабатывала успешно, и в таблице оказывались ДВЕ
учётные записи администратора. GetAdminUser берёт First() и о второй строке
не сообщает — на сервере оставалась вторая рабочая учётка с паролем, уже
напечатанным на экран, и ни один запрос об этом не говорил.
Заодно исправлено проглатывание ошибки хеширования: в ветке обновления
стояло `hash, _ := util.HashPassword(password)` внутри литерала map. При
отказе bcrypt в password_hash уезжала пустая строка, а на экран печатался
пароль, которым войти уже невозможно — VerifyPassword отклоняет всё, что не
bcrypt. Команда восстановления доступа умела молча его отобрать.
Тесты: doctor-readonly.test.ts дополнен поведенческой проверкой guard и
контролем набора раннеров в smoke; apps/cmd/reset_test.go проверяет обе ветки
на настоящей SQLite и отказ чтения при полностью работоспособной базе — ровно
тот случай, который прежний код превращал во второго администратора. Добавлена
dao.CountAdminUsers: до неё появление дубликата было ненаблюдаемым.
This commit is contained in:
+36
-2
@@ -23,8 +23,42 @@ Hardening-проход перед релизом `1.0.0`. Основная те
|
||||
невозможно воспользоваться.
|
||||
|
||||
Четвёртый проход — failure path и релизные гейты: восстановление после
|
||||
неудачной установки, которое умело отменить само себя, и два гейта сборки,
|
||||
проверявшие не то, что обещали.
|
||||
неудачной установки, которое умело отменить само себя, два гейта сборки,
|
||||
проверявшие не то, что обещали, и два свойства, которые были описаны, но не
|
||||
обеспечены — read-only у `doctor` и различение отказа базы у `reset-admin`.
|
||||
|
||||
### Изменено — свойства, ставшие инвариантами
|
||||
|
||||
- **`doctor` read-only по инварианту рантайма, а не по соглашению.**
|
||||
Принудительный `skipServiceStart` закрывал ровно одну ИЗВЕСТНУЮ мутацию —
|
||||
рестарт сервисов. Всё остальное в `smoke` держалось на том, что автор правки
|
||||
выбрал правильный раннер, а читающие команды (`test -s`, `grep -q`, `stat`,
|
||||
`sudo -u ... test`, `nft -c`) шли через мутирующий namespace. То есть
|
||||
настоящая мутация, случайно добавленная в `smoke`, ничем бы от них не
|
||||
отличалась и была бы разрешена в `doctor` молча.
|
||||
|
||||
Эти команды классифицированы честно, ожидание между попытками перестало быть
|
||||
подпроцессом `sleep` через мутирующий раннер, а сам `doctor` целиком
|
||||
выполняется под тем же read-only guard, что и PHASE 0 установки. Диагностика
|
||||
при этом не сузилась.
|
||||
|
||||
### Исправлено — `reset-admin`
|
||||
|
||||
- **Отказ базы трактовался как «администратора нет».** Слой данных специально
|
||||
различает `ErrAdminUserNotFound` и `ErrStorage`, но команда восстановления
|
||||
доступа склеивала их обычным `if err != nil { создать } else { обновить }`.
|
||||
Опасен здесь не только нарушенный смысл sentinel'ов: при транзиентном отказе
|
||||
чтения («database is locked») ветка создания отрабатывала успешно, и в
|
||||
таблице оказывались ДВЕ учётные записи администратора. `GetAdminUser` берёт
|
||||
`First()` и о второй строке не сообщает — то есть на сервере оставалась
|
||||
вторая рабочая учётка с паролем, уже напечатанным на экран, и ни один запрос
|
||||
об этом не говорил.
|
||||
|
||||
- **Ошибка хеширования пароля проглатывалась.** В ветке обновления стояло
|
||||
`hash, _ := util.HashPassword(password)` внутри литерала map. При отказе
|
||||
bcrypt в `password_hash` уезжала пустая строка, а на экран печатался пароль,
|
||||
которым войти уже невозможно: `VerifyPassword` отклоняет всё, что не является
|
||||
bcrypt-хешем. Команда восстановления доступа умела молча его отобрать.
|
||||
|
||||
### Исправлено — восстановление после неудачной операции
|
||||
|
||||
|
||||
Reference in New Issue
Block a user