Задачи 011-024: доработки по ревью кодовой базы и исправление находок
Ревью кодовой базы (docs/reviews/2026-09-26-codebase-review.md) и планы по каждой находке:
011 IP уникален в VRF и хранится в самом узком префиксе (addresses.vrf_id, составной FK
с каскадом при переносе VRF, миграция 0007 с остановкой на дублях).
012 Ограничение попыток входа (login_attempts, 429 + Retry-After), выравнивание времени
ответа, журнал без вытеснения анонимными событиями (миграция 0006).
013 Границы пагинации: отрицательные/чрезмерные limit/offset дают 422 вместо 500.
014 Экран адресов: страница свободных адресов арифметикой, пагинация в SQL.
015 Запрет адреса сети/broadcast, загрузка не выше 100 %.
016 Роль по умолчанию — viewer.
017 Проверка JWT_SECRET/ADMIN_PASSWORD при старте.
018 null в PATCH очищает текстовые поля; нейтральный текст конфликта БД.
019 Пакетная загрузка в списках вместо N+1.
020 Автоназначение адреса вне вложенных префиксов, с блокировкой префикса.
021 Advisory-lock при снятии прав администратора, уникальный lower(username) (миграция 0008).
022 Контейнер не от root, healthcheck, блокировка миграций, requirements.lock.
023 Экранирование LIKE, журнал отказов очистки, заголовки безопасности, учёт force-удаления,
отзыв токенов при смене пароля (claim pv, миграция 0005).
024 Исправление находок ревью 011-023 (docs/reviews/2026-09-26-changes-011-023-review.md):
сериализация попыток входа, запрет переноса адресов в адрес сети/broadcast, журнал входов,
запрет смены своего пароля через PATCH, валидация PATCH устройства, обновлён тест токенов.
Тесты: 14 passed. Документация: README.md, docs/changes/011-024, docs/reviews.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cd09ef0805
commit
13e17fbb47
57 files changed
+1748
-166
No files matched your search
@@ -0,0 +1,36 @@
|
||||
# Уникальность IP-адреса в VRF (изменение 011)
|
||||
|
||||
Находка ревью № 1 (`docs/reviews/2026-09-26-codebase-review.md`), серьёзность — высокая.
|
||||
|
||||
## Context
|
||||
Один и тот же IP можно завести и в родительском, и в дочернем префиксе одного VRF: уникальна только пара `(prefix_id, address)`.
|
||||
Воспроизведено: `198.51.100.5` создаётся в `/24` и во вложенном `/25` (оба 201), у родителя `used` завышен. Нужен инвариант:
|
||||
**адрес хранится только в самом узком префиксе своего VRF и уникален в VRF**, причём на уровне БД.
|
||||
|
||||
## Решение
|
||||
1. **Схема** (новая миграция Alembic):
|
||||
- `prefixes`: уникальное ограничение `(id, vrf_id)` — цель составного FK;
|
||||
- `addresses`: колонка `vrf_id` (NOT NULL, заполняется из `prefixes`), составной FK `(prefix_id, vrf_id) → prefixes(id, vrf_id)` **ON UPDATE CASCADE**
|
||||
(перенос префикса в другой VRF сам обновит адреса), уникальный индекс `(vrf_id, address)`.
|
||||
- Перед созданием индекса миграция ищет дубли и при их наличии **останавливается** с перечнем `VRF · адрес · префиксы` (данные не удаляются автоматически).
|
||||
Для разбора — скрипт `scripts/find_duplicate_addresses.py` (только чтение).
|
||||
2. **API** (`app/api/v1/prefixes.py`):
|
||||
- `create_address`: если адрес попадает в дочерний префикс того же VRF — 422 «Адрес принадлежит вложенному префиксу X, назначьте его там»; дубль в VRF — 409 (через `flush`).
|
||||
- `attach_to_tree` (создание префикса, `allocate_subnet`, перенос VRF): адреса родителя из диапазона нового префикса переносятся в него
|
||||
(`UPDATE addresses SET prefix_id = :new WHERE prefix_id = :parent AND address <<= :cidr`); число перенесённых — в `diff` записи `prefix.created` (`moved_addresses`).
|
||||
- `_move_to_vrf`: конфликт адресов в целевом VRF — 409 с перечнем (как для префиксов), без частичных изменений.
|
||||
3. **Модель** `Address`: `vrf_id` + ограничения из п. 1; `Address(...)` заполняет `vrf_id` из префикса.
|
||||
4. `_usage` не меняется: после инварианта двойного счёта нет.
|
||||
|
||||
## Файлы
|
||||
`alembic/versions/<next>_address_vrf_unique.py`, `app/models.py`, `app/api/v1/prefixes.py`, `scripts/find_duplicate_addresses.py`, `tests/test_api.py`, `README.md` (Модель данных).
|
||||
|
||||
## Тест (один сценарий)
|
||||
Родитель `/24` с адресом `.5`, создание дочернего `/25` → адрес переехал в дочерний; повторный `.5` в родителе → 422; `.5` в дочернем → 409; перенос дочернего в VRF с `.5` → 409.
|
||||
|
||||
## Проверка
|
||||
- Миграция на копии данных стенда `ipam_control_006`: без дублей — проходит; с искусственным дублем — останавливается с понятным сообщением.
|
||||
- `pytest -q`; вручную в UI: «Назначить адрес» в родителе на адрес из дочернего — понятная ошибка.
|
||||
|
||||
## Риски
|
||||
Изменение модели данных; на рабочей БД до миграции запустить скрипт поиска дублей. Зависимость: № 020 (автоназначение) опирается на этот инвариант.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Итог: уникальность IP-адреса в VRF (изменение 011)
|
||||
|
||||
## Что сделано
|
||||
- **Миграция `0007`:** `prefixes` — уникальность `(id, vrf_id)`; `addresses.vrf_id` (заполнен из префиксов), составной FK `(prefix_id, vrf_id)` → `prefixes` (`ON DELETE CASCADE`, `ON UPDATE CASCADE` — при переносе префикса в другой VRF адреса следуют автоматически), уникальность `(vrf_id, address)`.
|
||||
Перед изменениями миграция ищет дубли и **останавливается** с перечнем; найденные ранее в родителе адреса, попадающие в дочерний префикс, переносятся в самый узкий префикс. `scripts/find_duplicate_addresses.py` — поиск дублей (только чтение).
|
||||
- **API (`prefixes.py`):** `create_address` — адрес из диапазона вложенного префикса → 422 «назначьте его там»; дубль в VRF → 409; `rehome_addresses()` — создание префикса, `allocate_subnet` и перенос VRF забирают адреса родителя из своего диапазона (число — `moved_addresses` в журнале);
|
||||
перенос префикса в VRF с тем же адресом → 409 с перечнем (без частичных изменений).
|
||||
- `app/models.py`: `Address.vrf_id`, ограничения; `README.md`: раздел «Модель данных».
|
||||
|
||||
## Проверка
|
||||
Проверка: стенд `ipam_control_006` пересобран, миграции применены до 0008 (`alembic check` — расхождений нет); ручная проверка (скрипт во временной папке, в репозиторий не добавлялся); автотесты по условию этапа не писались. .5 в родителе → 201; создание дочернего /25 переносит .5; повторный .5 в родителе → 422, в дочернем → 409; перенос префикса в VRF с тем же адресом → 409; миграция на стенде (без дублей) прошла.
|
||||
Не проверено: остановка миграции на реальном дубле, перенос префикса без конфликта (rehome при смене VRF).
|
||||
|
||||
## Риски
|
||||
Изменение модели данных. Перед применением на рабочей БД — запустить `scripts/find_duplicate_addresses.py` и сделать резервную копию.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Ограничение попыток входа и защита журнала от вытеснения (изменение 012)
|
||||
|
||||
Находка ревью № 2, серьёзность — высокая.
|
||||
|
||||
## Context
|
||||
`POST /auth/login` не ограничен: возможен перебор паролей без авторизации. Каждая неудача пишет `session.failed` в журнал, а ротация по количеству
|
||||
(`max_entries`, 100 000) удаляет самые старые записи — анонимный клиент может вытеснить историю изменений. Дополнительно: при несуществующем логине argon2 не
|
||||
вызывается (разница во времени ответа раскрывает существование логина), у пароля нет ограничения длины.
|
||||
|
||||
## Решение
|
||||
1. **Учёт попыток** — таблица `login_attempts (id, ts, client_ip INET, username)` (новая миграция), по образцу `ClearAttempt`; индексы по `(client_ip, ts)` и `(lower(username), ts)`.
|
||||
2. **Лимиты** (константы в `app/api/v1/auth.py`, при необходимости — в `Settings`):
|
||||
- по логину: 5 неудач за 10 минут → блокировка входа для этого логина на 10 минут;
|
||||
- по IP: 20 неудач за 10 минут → блокировка IP на 10 минут.
|
||||
Ответ при блокировке — 429 `{"message", "retry_after_seconds"}` + заголовок `Retry-After`; верный пароль во время блокировки тоже отклоняется. Успешный вход очищает счётчик логина.
|
||||
3. **Время ответа:** для несуществующего логина — проверка по фиктивному argon2-хэшу (вычисляется один раз при старте). `LoginIn`: `username` ≤ 100, `password` ≤ 128 символов.
|
||||
4. **Журнал без вытеснения:**
|
||||
- `session.failed` пишется только для первой неудачи логина/IP в окне; при срабатывании блокировки — одна запись `session.locked` (`diff`: логин, IP, число попыток, до какого времени);
|
||||
- `rotate()` при превышении `max_entries` сначала удаляет самые старые `session.failed`, затем остальное — события изменений данных уходят последними.
|
||||
5. **Очистка** `login_attempts` старше суток — в том же часовом цикле ротации.
|
||||
6. **UI:** на экране входа — текст 429 с временем до разблокировки (уже выводится `err.message`).
|
||||
|
||||
## Файлы
|
||||
`alembic/versions/<next>_login_attempts.py`, `app/models.py`, `app/api/v1/auth.py`, `app/schemas.py`, `app/rotation.py`, `web/app.js` (при необходимости), `tests/test_journal.py` или новый `tests/test_auth.py`, `README.md`.
|
||||
|
||||
## Тест (один сценарий)
|
||||
6 неверных паролей для временного пользователя → на 6-й 429 с `retry_after_seconds`; верный пароль тоже 429; в журнале одна `session.failed` и одна `session.locked`.
|
||||
Очистка блокировки в тесте — прямым SQL (фикстура `db`).
|
||||
|
||||
## Проверка
|
||||
`pytest -q`; вручную: серия неверных входов в UI → сообщение о блокировке; через 10 минут вход работает.
|
||||
|
||||
## Риски
|
||||
Блокировка по логину позволяет «запереть» чужую учётную запись перебором — поэтому окно короткое (10 минут) и событие видно в журнале.
|
||||
Если стенд за reverse-proxy, без `TRUSTED_PROXIES` все клиенты делят один IP — лимит по IP заденет всех; отметить в README.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Итог: ограничение попыток входа (изменение 012)
|
||||
|
||||
## Что сделано
|
||||
- Миграция `0006`: таблица `login_attempts` (`ts`, `client_ip`, `username`); модель `LoginAttempt`.
|
||||
- `app/api/v1/auth.py`: 5 неудач на логин и 20 на IP за 10 минут → блокировка на 10 минут (429, заголовок `Retry-After`, `retry_after_seconds`, текст с минутами); во время блокировки пароль не проверяется. Успешный вход очищает попытки логина.
|
||||
Для несуществующего логина проверяется фиктивный argon2-хэш (выравнивание времени). `LoginIn`: логин ≤ 100, пароль ≤ 128.
|
||||
- Журнал: `session.failed` — только первая неудача в окне, при срабатывании блокировки одна запись `session.locked` (область, число попыток, время); красный бейдж в UI.
|
||||
- `app/rotation.py`: при превышении `max_entries` первыми удаляются `session.failed`; `login_attempts` старше суток чистятся в часовом цикле.
|
||||
- `README.md`: раздел «Журнал».
|
||||
|
||||
## Проверка
|
||||
Проверка: стенд `ipam_control_006` пересобран, миграции применены до 0008 (`alembic check` — расхождений нет); ручная проверка (скрипт во временной папке, в репозиторий не добавлялся); автотесты по условию этапа не писались. 5 неверных паролей → 401×4 и 429; верный пароль при блокировке → 429 + `Retry-After`; в журнале по одной записи `session.failed` и `session.locked`; пароль > 128 символов → 422.
|
||||
Не проверено: снятие блокировки по истечении 10 минут и лимит по IP (20 попыток).
|
||||
|
||||
## Ограничения
|
||||
За reverse-proxy без `TRUSTED_PROXIES` все клиенты делят один IP — лимит по IP заденет всех (описано в README). Блокировка по логину позволяет «запереть» чужую учётную запись — событие видно в журнале.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Границы пагинации: отрицательные limit/offset дают 500 (изменение 013)
|
||||
|
||||
Находка ревью № 3, серьёзность — средняя.
|
||||
|
||||
## Context
|
||||
Во всех списках `limit: int = Query(…, le=…)` и `offset: int = 0` без нижней границы. `GET /audit?limit=-1` и `GET /isps?offset=-1` отвечают 500
|
||||
(PostgreSQL отвергает отрицательные LIMIT/OFFSET). Ожидаемо — 422 с указанием поля.
|
||||
|
||||
## Решение
|
||||
1. `app/api/v1/__init__.py` (или `app/services.py`): общая зависимость
|
||||
`Paging(limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0, le=10_000_000))`, с параметризацией верхнего `limit` там, где он отличается
|
||||
(`/prefixes` — 1000, `/organizations` — 500).
|
||||
2. Заменить объявления в `refs.py` (организации, устройства, операторы), `prefixes.py` (префиксы, адреса), `journal.py` (`/audit`), `users.py`.
|
||||
Формат ответа, значения по умолчанию и верхние пределы остаются прежними — меняется только отказ на недопустимых значениях.
|
||||
3. Верхний предел `offset` в `list_addresses` — см. № 014 (там он существенен для ресурсоёмкой ветки `status=free`).
|
||||
|
||||
## Файлы
|
||||
`app/api/v1/{refs,prefixes,journal,users}.py`, модуль с зависимостью, `tests/test_api.py`, `README.md` (раздел API: параметры пагинации).
|
||||
|
||||
## Тест
|
||||
Параметризованный: для 3–4 списков `limit=-1`, `limit=0`, `offset=-1` → 422 с полем в `fields`.
|
||||
|
||||
## Проверка
|
||||
`pytest -q`; UI не затронут (передаёт корректные значения) — пройти основные экраны.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Итог: границы пагинации (изменение 013)
|
||||
|
||||
## Что сделано
|
||||
- Во всех списках (`/organizations`, `/devices`, `/isps`, `/users`, `/prefixes`, `/prefixes/{id}/addresses`, `/audit`): `limit` — `ge=1` с прежними верхними пределами, `offset` — `ge=0, le=MAX_OFFSET` (10 000 000, константа в `app/services.py`). Недопустимые значения → 422 вместо 500.
|
||||
- Отклонение от плана: вместо общей зависимости-класса пагинации использованы единые параметры `Query` и константа `MAX_OFFSET` (проще и без изменения сигнатур).
|
||||
- `README.md`: раздел API.
|
||||
|
||||
## Проверка
|
||||
Проверка: стенд `ipam_control_006` пересобран, миграции применены до 0008 (`alembic check` — расхождений нет); ручная проверка (скрипт во временной папке, в репозиторий не добавлялся); автотесты по условию этапа не писались. `limit=-1` и `offset=-1` → 422; `offset` выше предела → 422.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Экран адресов: ресурсоёмкие ветки list_addresses (изменение 014)
|
||||
|
||||
Находка ревью № 4, серьёзность — средняя (DoS с ролью viewer).
|
||||
|
||||
## Context
|
||||
`GET /prefixes/{id}/addresses` (`app/api/v1/prefixes.py`, `list_addresses`):
|
||||
- при `status=free` собирает в память `offset + limit` свободных адресов; `offset` не ограничен → на IPv6-префиксе один запрос с `offset=10^8` занимает CPU и память воркера;
|
||||
- без фильтров основной запрос идёт **без LIMIT/OFFSET** — на крупных подсетях загружаются все адреса ради одной страницы.
|
||||
|
||||
## Решение
|
||||
1. **Свободные адреса без материализации:** функция `free_page(net, occupied_sorted, offset, limit)` в `app/services.py` — идёт по занятым диапазонам,
|
||||
считает длины свободных промежутков арифметически, пропускает `offset` без перебора и возвращает только `limit` адресов. Для IPv4 ≤ /30 учитывает исключение адреса сети и broadcast.
|
||||
Занятые адреса — одним запросом `SELECT address … ORDER BY address` (для пула из 10⁵ адресов — приемлемо; при росте — курсор по ключу).
|
||||
2. **Режимы:**
|
||||
- `status` = assigned/reserved/deprecated или поиск `q` → пагинация в SQL (`ORDER BY address LIMIT/OFFSET`, `total` — `count()`);
|
||||
- без фильтров и `cap <= FREE_LISTING_LIMIT` (4096) → как сейчас, смешанный список (он ограничен размером подсети);
|
||||
- без фильтров и `cap > FREE_LISTING_LIMIT` → только записанные адреса, пагинация в SQL (сейчас — выборка всего и срез в Python);
|
||||
- `status=free` → `free_page`, `total = summary.free`.
|
||||
3. `offset` — верхний предел как в № 013; для `status=free` дополнительно `offset <= summary.free`.
|
||||
4. Ответ API и поведение UI (кнопка «Показать ещё 100») не меняются.
|
||||
|
||||
## Файлы
|
||||
`app/services.py`, `app/api/v1/prefixes.py`, `tests/test_api.py`, `README.md`.
|
||||
|
||||
## Тест
|
||||
- Чистая функция `free_page`: IPv4 /24 с занятыми `.1–.3` → offset 0 даёт `.4…`; offset за пределами → пусто; IPv6 /64 с `offset=10^12` — мгновенно.
|
||||
- API: `status=free&offset=1000000` на IPv6 /64 отвечает быстро (порог времени в тесте не ставим — проверяем корректность адресов).
|
||||
|
||||
## Проверка
|
||||
`pytest -q`; экран адресов демо-префиксов `10.10.1.0/24` и `fd00::/8` в UI: списки, фильтр «Свободен», «Показать ещё 100». Замер времени ответа до/после на `/16` с 60 000 адресов (скрипт в scratchpad).
|
||||
@@ -0,0 +1,11 @@
|
||||
# Итог: экран адресов — ресурсоёмкие ветки (изменение 014)
|
||||
|
||||
## Что сделано
|
||||
- `app/services.py`: `free_page(net, occupied, offset, limit)` — страница свободных адресов арифметикой по промежуткам (без перебора; IPv4 ≤ /30 без адреса сети и broadcast).
|
||||
- `list_addresses` переписан: `status=free` → `free_page`, `total = summary.free`; фильтры `status`/`q` и подсети > /20 без фильтров — пагинация и `count` в SQL (раньше загружались все адреса и нарезались в Python); малые подсети без фильтров — смешанный список, как раньше (ограничен размером подсети).
|
||||
`offset` ≤ 10 000 000 (изменение 013). Ответ API и поведение UI не менялись.
|
||||
- `README.md`: раздел «Модель данных».
|
||||
|
||||
## Проверка
|
||||
Проверка: стенд `ipam_control_006` пересобран, миграции применены до 0008 (`alembic check` — расхождений нет); ручная проверка (скрипт во временной папке, в репозиторий не добавлялся); автотесты по условию этапа не писались. IPv6 /48, `status=free&offset=9000000` → 200 за ~0,02 с; `offset` выше предела → 422; первые свободные адреса, смешанный список /25 (126 строк) корректны.
|
||||
Не проверено: замер на /16 с десятками тысяч адресов; экран адресов в браузере.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Запрет адреса сети и broadcast (изменение 015)
|
||||
|
||||
Находка ревью № 5, серьёзность — средняя.
|
||||
|
||||
## Context
|
||||
`create_address` проверяет только `ip in network`: в `198.51.100.0/25` назначаются `.0` и `.127` (воспроизведено). При этом `capacity()` для IPv4 ≤ /30
|
||||
вычитает эти два адреса, поэтому `free = cap - stored` занижается, загрузка может превысить 100 %.
|
||||
|
||||
## Решение
|
||||
1. `app/services.py`: `usable(net, ip) -> bool` — для IPv4 с длиной ≤ 30 ложь для адреса сети и broadcast; /31, /32 и IPv6 — без ограничений (как `capacity()` и `next_free`).
|
||||
2. `create_address` (`app/api/v1/prefixes.py`): при `not usable` — 422 «Адрес сети/broadcast нельзя назначить: 198.51.100.0 — адрес сети 198.51.100.0/25».
|
||||
3. **Существующие данные:** миграция не нужна. Скрипт `scripts/find_unusable_addresses.py` (только чтение) выводит такие записи; решение по ним — за администратором
|
||||
(в SUMMARY — результат по стенду). До очистки `utilization` ограничивается 100 % (`min(…, 100)` в `utilization()`), чтобы не показывать >100 %.
|
||||
4. `allocate_next` уже использует `net.hosts()` — без изменений.
|
||||
|
||||
## Файлы
|
||||
`app/services.py`, `app/api/v1/prefixes.py`, `scripts/find_unusable_addresses.py`, `tests/test_api.py`, `README.md` (Модель данных).
|
||||
|
||||
## Тест
|
||||
`/25`: `.0` → 422, `.127` → 422, `.1` → 201; `/31`: оба адреса → 201.
|
||||
|
||||
## Проверка
|
||||
`pytest -q`; UI «Назначить адрес» с `.0` — сообщение в форме у поля.
|
||||
@@ -0,0 +1,10 @@
|
||||
# Итог: адрес сети и broadcast (изменение 015)
|
||||
|
||||
## Что сделано
|
||||
- `app/services.py`: `network_role()` (IPv4, префикс ≤ /30 — как в `capacity()`); `utilization()` ограничена 100 %.
|
||||
- `create_address`: адрес сети/broadcast → 422 с понятным текстом. Автоназначение уже использовало `hosts()`.
|
||||
- `scripts/find_unusable_addresses.py` — поиск уже внесённых таких адресов (только чтение); на стенде найдено 0.
|
||||
- `README.md`: раздел «Модель данных».
|
||||
|
||||
## Проверка
|
||||
Проверка: стенд `ipam_control_006` пересобран, миграции применены до 0008 (`alembic check` — расхождений нет); ручная проверка (скрипт во временной папке, в репозиторий не добавлялся); автотесты по условию этапа не писались. `.0` и `.255` в /24 → 422.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Роль по умолчанию — «Просмотр» (изменение 016)
|
||||
|
||||
Находка ревью № 6, серьёзность — средняя.
|
||||
|
||||
## Context
|
||||
`POST /users` без поля `role` создаёт администратора (`UserIn.role = Role.admin`, воспроизведено); модель `User.role` тоже `default=Role.admin`.
|
||||
UI передаёт роль явно, но клиенты API и скрипты по умолчанию получают максимальные права. Принцип наименьших привилегий требует `viewer`.
|
||||
|
||||
## Решение
|
||||
1. `app/schemas.py`: `UserIn.role: Role = Role.viewer`.
|
||||
2. `app/models.py`: `User.role` — `default=Role.viewer`. Первичный администратор из `.env` создаётся в `seed()` с явным `role=Role.admin` (уже так) — не затрагивается.
|
||||
Серверного значения по умолчанию в БД нет → миграция не нужна.
|
||||
3. `README.md` (раздел «Пользователи и роли»): роль по умолчанию — `viewer`.
|
||||
|
||||
## Файлы
|
||||
`app/schemas.py`, `app/models.py`, `tests/test_users.py`, `README.md`.
|
||||
|
||||
## Тест
|
||||
В `test_users_management`: создание без `role` → `role == "viewer"`.
|
||||
|
||||
## Проверка
|
||||
`pytest -q`; UI «Новый пользователь» по-прежнему предлагает «Просмотр».
|
||||
@@ -0,0 +1,8 @@
|
||||
# Итог: роль по умолчанию — «Просмотр» (изменение 016)
|
||||
|
||||
## Что сделано
|
||||
- `app/schemas.py`: `UserIn.role` по умолчанию `Role.viewer`; `app/models.py`: `User.role` — `default=Role.viewer`. Первичный администратор из `.env` по-прежнему создаётся с явной ролью admin. Миграция не нужна.
|
||||
- `README.md`: раздел «Пользователи и роли».
|
||||
|
||||
## Проверка
|
||||
Проверка: стенд `ipam_control_006` пересобран, миграции применены до 0008 (`alembic check` — расхождений нет); ручная проверка (скрипт во временной папке, в репозиторий не добавлялся); автотесты по условию этапа не писались. `POST /users` без `role` → `viewer`.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Проверка секретов при старте (изменение 017)
|
||||
|
||||
Находка ревью № 7, серьёзность — средняя.
|
||||
|
||||
## Context
|
||||
`Settings.jwt_secret = "dev-only-secret"` (`app/config.py`). При запуске без `JWT_SECRET` (локально, другое развёртывание) токены подписываются
|
||||
общеизвестным ключом — их можно подделать для любого логина. В compose пустое значение даёт 500 при входе (PyJWT отвергает пустой ключ) — ошибка всплывает поздно.
|
||||
Аналогично: при пустой БД и пустом `ADMIN_PASSWORD` администратор молча не создаётся.
|
||||
|
||||
## Решение
|
||||
1. `app/config.py`: `jwt_secret: str` без значения по умолчанию; валидатор — не короче 32 символов и не из списка известных заглушек (`change-me`, `dev-only-secret`, `secret`).
|
||||
Ошибка — при импорте настроек, т.е. при старте: контейнер падает с понятным сообщением «JWT_SECRET не задан или слишком короткий (python scripts/gen_env.py)».
|
||||
2. `app/main.py` `seed()`: пустая таблица `users` и пустой `ADMIN_PASSWORD` → `logging.warning` с инструкцией (не падение: БД может заполняться иначе);
|
||||
`ADMIN_PASSWORD` короче 8 символов → отказ старта (иначе создаётся пароль слабее, чем разрешает UI).
|
||||
3. `.env.example`: пометка, что `JWT_SECRET` ≥ 32 символов; `scripts/gen_env.py` уже генерирует 64 символа — без изменений.
|
||||
4. `alembic/env.py` импортирует `settings` — проверить, что миграции не требуют `JWT_SECRET` (при необходимости вынести `database_url` в отдельный доступ).
|
||||
|
||||
## Файлы
|
||||
`app/config.py`, `app/main.py`, `alembic/env.py` (при необходимости), `.env.example`, `README.md` (Быстрый старт).
|
||||
|
||||
## Тест
|
||||
Юнит-тест без БД: `Settings(jwt_secret="short")` → `ValidationError`; `Settings(jwt_secret="x"*32)` — успешно.
|
||||
|
||||
## Проверка
|
||||
Пересборка стенда с текущим `.env` — старт успешен; запуск контейнера с `JWT_SECRET=` → контейнер завершается с сообщением в логах; `alembic upgrade head` работает.
|
||||
@@ -0,0 +1,10 @@
|
||||
# Итог: проверка секретов при старте (изменение 017)
|
||||
|
||||
## Что сделано
|
||||
- `app/config.py`: `jwt_secret` без встроенного значения; `validate_secrets()` — длина ≥ 32 и не заглушка (`change-me`, `dev-only-secret` …), `ADMIN_PASSWORD` (если задан) ≥ 8. Вызывается при импорте `app/main.py`: приложение не стартует с небезопасной конфигурацией.
|
||||
- Отклонение от плана: проверка вынесена из `Settings` в отдельную функцию, чтобы `alembic` (импортирует `settings`) не требовал `JWT_SECRET`.
|
||||
- `app/main.py` `seed()`: пустая БД и пустой `ADMIN_PASSWORD` → предупреждение в логе.
|
||||
- `.env.example`, `README.md` (быстрый старт).
|
||||
|
||||
## Проверка
|
||||
Проверка: стенд `ipam_control_006` пересобран, миграции применены до 0008 (`alembic check` — расхождений нет); ручная проверка (скрипт во временной папке, в репозиторий не добавлялся); автотесты по условию этапа не писались. Импорт приложения с `JWT_SECRET=short` → `RuntimeError` с инструкцией; с текущим `.env` — старт успешен.
|
||||
@@ -0,0 +1,23 @@
|
||||
# PATCH адреса: null даёт ложный 409 (изменение 018)
|
||||
|
||||
Находка ревью № 8, серьёзность — низкая.
|
||||
|
||||
## Context
|
||||
`update_address` (`app/api/v1/prefixes.py`) использует `model_dump(exclude_unset=True)` без `exclude_none`: `{"description": null}` пишет NULL в колонку NOT NULL →
|
||||
`IntegrityError` → 409 «Запись с такими значениями уже существует» (воспроизведено). Остальные PATCH-эндпоинты исключают `None`.
|
||||
|
||||
## Решение
|
||||
1. Единая семантика PATCH: `null` для текстовых полей — «очистить» (записать `""`), для `status` — 422 (обязательное поле), для `device_id` — отвязать устройство (`None`, как сейчас).
|
||||
Реализация — в `AddressUpdate` через `field_validator`: текстовые `None → ""`, `status` — тип `AddressStatus` без `None`-варианта (поле по-прежнему необязательное).
|
||||
2. Проверить тем же правилом `PrefixUpdate`, `DeviceUpdate`, `VrfUpdate`, `UserUpdate`: где `exclude_none` скрывает намерение очистить поле (`note`, `description`) — применить то же приведение.
|
||||
3. Общий обработчик `IntegrityError` оставить как страховку, но текст «Конфликт с существующими данными» (уже так в `main.py`); сообщение
|
||||
«Запись с такими значениями уже существует» в `commit()` по умолчанию заменить на нейтральное — сейчас оно вводит в заблуждение при нарушении NOT NULL/FK.
|
||||
|
||||
## Файлы
|
||||
`app/schemas.py`, `app/api/v1/prefixes.py`, `app/services.py` (текст по умолчанию), `tests/test_api.py`.
|
||||
|
||||
## Тест
|
||||
PATCH адреса `{"description": null}` → 200 и `description == ""`; `{"status": null}` → 422.
|
||||
|
||||
## Проверка
|
||||
`pytest -q`; в UI правка адреса с очисткой описания.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Итог: null в PATCH (изменение 018)
|
||||
|
||||
## Что сделано
|
||||
- `app/schemas.py`: общий валидатор `_blank` — `null` в текстовых полях `AddressUpdate` (`dns_name`, `description`, `note`), `PrefixUpdate` (`description`, `note`), `DeviceUpdate` (`mac`, `note`), `VrfUpdate` (`route_target`, `note`) означает «очистить» (пустая строка);
|
||||
`status` адреса = `null` → 422; `device_id: null` по-прежнему отвязывает устройство.
|
||||
- `app/services.py`: сообщение по умолчанию для конфликтов БД заменено на нейтральное (`CONFLICT_MSG`: «Конфликт с существующими данными: проверьте уникальность значений и связанные объекты»);
|
||||
сообщения с явным текстом (дубль префикса, логина и т.п.) не менялись.
|
||||
- `README.md`: раздел API.
|
||||
|
||||
## Проверка
|
||||
Проверка: стенд `ipam_control_006` пересобран, миграции применены до 0008 (`alembic check` — расхождений нет); ручная проверка (скрипт во временной папке, в репозиторий не добавлялся); автотесты по условию этапа не писались. `{"description": null}` → 200 и `""`; `{"status": null}` → 422.
|
||||
@@ -0,0 +1,28 @@
|
||||
# N+1 запросов в списках (изменение 019)
|
||||
|
||||
Находка ревью № 9, серьёзность — низкая (производительность).
|
||||
|
||||
## Context
|
||||
Счётчики и связанные данные догружаются отдельными запросами на каждую строку: `_device_out` (2 запроса на устройство — до 1 000 на странице),
|
||||
`_isp_out` (организация), `_vrf_out`, `_type_out`, `_org_out` (счётчики), «Обзор» (`_prefix_outs` для всех активных префиксов с `_depths`/`_capacities` по организациям).
|
||||
|
||||
## Решение
|
||||
1. **Пакетные выходные функции** — принимают список строк и делают фиксированное число запросов:
|
||||
- устройства: один запрос адресов `WHERE device_id IN (…)` + словарь типов (типов мало — один `SELECT` всех);
|
||||
- операторы: `selectinload(Isp.networks)` + словарь организаций по `IN`;
|
||||
- VRF / типы / организации: счётчики одним `GROUP BY` по `IN (…)`;
|
||||
- одиночные эндпоинты (`GET /devices/{id}` и т.п.) вызывают пакетную функцию со списком из одного элемента.
|
||||
2. **Обзор:** считать ёмкость и загрузку одним SQL-запросом по листовым активным IPv4-префиксам (лист — префикс без детей) вместо полной сборки `PrefixOut` по всем префиксам;
|
||||
`top_prefixes` — `_prefix_outs` только для 4 выбранных.
|
||||
3. Формат ответов не меняется.
|
||||
4. Контроль: в тестовом режиме — счётчик запросов через событие `before_cursor_execute` (фикстура), чтобы зафиксировать «не больше K запросов на список».
|
||||
|
||||
## Файлы
|
||||
`app/api/v1/refs.py`, `app/api/v1/overview.py`, `app/api/v1/prefixes.py` (при необходимости), `tests/test_api.py`.
|
||||
|
||||
## Тест
|
||||
Один тест уровня функций (сессия SQLAlchemy к БД стенда, счётчик через `before_cursor_execute`): пакетный вывод 20 устройств — не больше 3 запросов.
|
||||
Внешние API-тесты счётчик не видят, поэтому тест вызывает функции напрямую.
|
||||
|
||||
## Проверка
|
||||
`pytest -q`; сравнение ответов API до/после на демо-данных (скрипт диффа JSON в scratchpad) — идентичны; замер времени `GET /devices?limit=500` на синтетических данных.
|
||||
@@ -0,0 +1,10 @@
|
||||
# Итог: N+1 запросов в списках (изменение 019)
|
||||
|
||||
## Что сделано
|
||||
- `app/api/v1/refs.py`: пакетные выходные функции `_org_outs`, `_vrf_outs`, `_type_outs`, `_device_outs`, `_isp_outs` — счётчики и связи одним `GROUP BY`/`IN` на страницу; операторы — `selectinload(Isp.networks)`. Одиночные `*_out` — обёртки над ними.
|
||||
- `app/api/v1/prefixes.py`: `_prefix_outs` берёт названия VRF одним запросом (раньше — по запросу на каждый префикс, в том числе на «Обзоре»).
|
||||
- Отклонение от плана: «Обзор» не переписан на единый SQL-запрос (ёмкость считается в Python по CIDR) — устранён только N+1 по VRF; остальные запросы «Обзора» — постоянное число на организацию.
|
||||
- Формат ответов не менялся.
|
||||
|
||||
## Проверка
|
||||
Проверка: стенд `ipam_control_006` пересобран, миграции применены до 0008 (`alembic check` — расхождений нет); ручная проверка (скрипт во временной папке, в репозиторий не добавлялся); автотесты по условию этапа не писались. Все списки и `/overview` отвечают 200; замеры количества запросов не проводились.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Автоназначение адреса с учётом вложенных префиксов (изменение 020)
|
||||
|
||||
Находка ревью № 10 (и часть № 11 — блокировка), серьёзность — низкая. Зависит от № 011.
|
||||
|
||||
## Context
|
||||
`next_free` (`app/services.py`) исключает только адреса самого пула: если внутри пула есть дочерний префикс, выданный адрес может попасть в его диапазон.
|
||||
Функция перебирает хосты подряд и загружает все занятые адреса в множество. `allocate_next` не блокирует префикс: при параллельных запросах один получает 409 «повторите».
|
||||
|
||||
## Решение
|
||||
1. `next_free_address(net, occupied)` — по образцу `next_free_subnet` (перескок за занятые диапазоны, без перебора): занятыми считаются адреса пула,
|
||||
диапазоны дочерних префиксов того же VRF и (для IPv4 ≤ /30) адрес сети и broadcast.
|
||||
2. `allocate_next`: `SELECT … FOR UPDATE` строки префикса (как в `allocate_subnet`) — параллельные запросы сериализуются и получают разные адреса.
|
||||
3. Старую `next_free` удалить.
|
||||
|
||||
## Файлы
|
||||
`app/services.py`, `app/api/v1/prefixes.py`, `tests/test_api.py`, `README.md` (автоназначение).
|
||||
|
||||
## Тест
|
||||
Пул `/24` с дочерним `/30` (`.0–.3`) и занятым `.5`: `POST …/addresses/next` → `.4`; ещё раз → `.6`.
|
||||
Параллельно 5 запросов (потоки) → 5 разных адресов, все 201.
|
||||
|
||||
## Проверка
|
||||
`pytest -q`; UI не меняется (кнопка автоназначения отсутствует в UI — проверка через Swagger).
|
||||
@@ -0,0 +1,10 @@
|
||||
# Итог: автоназначение адреса с учётом вложенных префиксов (изменение 020)
|
||||
|
||||
## Что сделано
|
||||
- `app/services.py`: `next_free_address(net, occupied)` — перескок за занятые диапазоны, без перебора; для IPv4 ≤ /30 без адреса сети и broadcast. Старая `next_free` удалена.
|
||||
- `app/api/v1/prefixes.py`: общий `_busy_ranges()` (вложенные префиксы любой глубины и адреса префикса) используется и для выбора подсети, и для автоназначения адреса; `allocate_next` блокирует строку префикса (`FOR UPDATE`) — параллельные запросы получают разные адреса.
|
||||
- `README.md`: раздел API.
|
||||
|
||||
## Проверка
|
||||
Проверка: стенд `ipam_control_006` пересобран, миграции применены до 0008 (`alembic check` — расхождений нет); ручная проверка (скрипт во временной папке, в репозиторий не добавлялся); автотесты по условию этапа не писались. Пул /24 с дочерним /25: автоназначение выдало `198.51.100.128` (вне вложенного префикса).
|
||||
Не проверено: параллельные запросы (5 потоков).
|
||||
@@ -0,0 +1,24 @@
|
||||
# Гонки: последний администратор и регистр логина (изменение 021)
|
||||
|
||||
Находка ревью № 11, серьёзность — низкая. Блокировка автоназначения адреса — в № 020.
|
||||
|
||||
## Context
|
||||
- `update_user` / `delete_user` проверяют «последний активный администратор» через `count()` без блокировки: два администратора, одновременно отключающие друг друга,
|
||||
могут оставить систему без администратора.
|
||||
- Логин уникален без учёта регистра только на уровне проверки в коде; в БД `users.username` уникален с учётом регистра — параллельное создание `Ivanov`/`ivanov` проходит.
|
||||
|
||||
## Решение
|
||||
1. **Сериализация изменений администраторов:** в `update_user` и `delete_user`, если операция может лишить учётную запись прав администратора (`loses_admin` / удаление активного admin),
|
||||
до проверки выполнить `pg_advisory_xact_lock(<USERS_ADMIN_LOCK>)`; проверка и изменение — в одной транзакции. Остальные изменения пользователей не блокируются.
|
||||
2. **Уникальность логина в БД:** новая миграция — уникальный индекс `lower(username)`; перед созданием миграция проверяет дубли по регистру и останавливается с перечнем.
|
||||
Предварительная проверка в `create_user` остаётся (даёт понятный 409), `flush` ловит гонку.
|
||||
3. Вход: поиск пользователя остаётся точным по регистру (логин = `sub` токена); при желании — отдельное решение, не в этом изменении.
|
||||
|
||||
## Файлы
|
||||
`app/api/v1/users.py`, `alembic/versions/<next>_users_lower_username.py`, `app/models.py` (`Index`), `tests/test_users.py`.
|
||||
|
||||
## Тест
|
||||
Два активных администратора (временные), параллельно (потоки) каждый отключает другого → ровно одна операция успешна, вторая 409; активный администратор остаётся.
|
||||
|
||||
## Проверка
|
||||
`pytest -q`; миграция на стенде проходит (дублей нет).
|
||||
@@ -0,0 +1,10 @@
|
||||
# Итог: гонки при изменении администраторов и регистр логина (изменение 021)
|
||||
|
||||
## Что сделано
|
||||
- `app/api/v1/users.py`: `pg_advisory_xact_lock(703002)` до чтения пользователя и подсчёта администраторов — в `update_user` (если меняются роль или доступ) и в `delete_user`: проверка «последний администратор» и изменение выполняются под блокировкой.
|
||||
- Миграция `0008`: уникальный индекс `lower(username)` (перед созданием проверка дублей с остановкой и перечнем); `app/models.py`: `Index("uq_users_lower_username", …)`.
|
||||
- `README.md`: раздел «Пользователи и роли».
|
||||
|
||||
## Проверка
|
||||
Проверка: стенд `ipam_control_006` пересобран, миграции применены до 0008 (`alembic check` — расхождений нет); ручная проверка (скрипт во временной папке, в репозиторий не добавлялся); автотесты по условию этапа не писались. Логин `SMOKE-U` при существующем `smoke-u` → 409; миграция на стенде прошла.
|
||||
Не проверено: параллельное отключение двух администраторов.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Эксплуатация: контейнер, миграции, TLS, зависимости (изменение 022)
|
||||
|
||||
Находка ревью № 12, серьёзность — низкая.
|
||||
|
||||
## Context
|
||||
- `Dockerfile`: процесс работает от root, нет `HEALTHCHECK`; у сервиса `app` в compose нет `healthcheck`, хотя `/healthz` есть.
|
||||
- `alembic upgrade head` в `CMD` выполняется при старте каждой реплики — при масштабировании миграции гоняются параллельно.
|
||||
- Приложение отдаётся по HTTP на `0.0.0.0:8088`: JWT и пароли идут открытым текстом по LAN.
|
||||
- Зависимости заданы диапазонами без lock-файла — сборки невоспроизводимы.
|
||||
|
||||
## Решение
|
||||
1. **Dockerfile:** пользователь `app` (uid 10001), `USER app`; `HEALTHCHECK` через `python -c` запрос к `/healthz` (curl в slim-образе отсутствует).
|
||||
2. **Compose:** `healthcheck` у `app`; `restart: unless-stopped` у обоих сервисов; порт приложения по умолчанию — `127.0.0.1:${APP_PORT}` с переменной `APP_BIND` (по умолчанию 127.0.0.1)
|
||||
для явного открытия в LAN.
|
||||
3. **Миграции:** в `alembic/env.py` — `pg_advisory_lock` на время `run_migrations` (одна реплика мигрирует, остальные ждут и видят актуальную схему).
|
||||
4. **TLS:** раздел README «Публикация» — пример блока Caddy (на хосте уже есть контейнер `caddy`) с reverse-proxy на `127.0.0.1:${APP_PORT}` и `TRUSTED_PROXIES` = адрес сети Docker прокси.
|
||||
Конфигурацию Caddy на хосте не меняем — только документация.
|
||||
5. **Зависимости:** `requirements.lock` через `pip-compile` (pip-tools в `requirements-dev.txt`); `Dockerfile` ставит из lock-файла; обновление — командой из README.
|
||||
|
||||
## Файлы
|
||||
`Dockerfile`, `docker-compose.yml`, `alembic/env.py`, `requirements.lock`, `requirements-dev.txt`, `.env.example`, `README.md`.
|
||||
|
||||
## Проверка
|
||||
- Пересборка стенда `ipam_control_006`: контейнер `healthy`, процесс не root (`docker exec … id`), тесты `pytest -q` проходят.
|
||||
- Два одновременных `docker compose run app alembic upgrade head` на пустой БД — без ошибок.
|
||||
- Изменение `APP_BIND` по умолчанию меняет доступность с LAN — **согласовать с пользователем перед внедрением** (сейчас UI открывается по 192.168.5.9:8088).
|
||||
@@ -0,0 +1,13 @@
|
||||
# Итог: эксплуатация — контейнер, миграции, TLS, зависимости (изменение 022)
|
||||
|
||||
## Что сделано
|
||||
- `Dockerfile`: пользователь `app` (uid 10001), `HEALTHCHECK` через `urllib` к `/healthz`, установка из `requirements.lock`.
|
||||
- `docker-compose.yml`: `restart: unless-stopped`, healthcheck у `app`, порт `${APP_BIND:-0.0.0.0}:${APP_PORT}:8000`.
|
||||
**Отклонение от плана:** значение `APP_BIND` по умолчанию — `0.0.0.0` (как раньше), а не `127.0.0.1`, чтобы не отключить доступ из LAN (`192.168.5.9:8088`) без согласования; для закрытия порта задайте `APP_BIND=127.0.0.1` в `.env`.
|
||||
- `alembic/env.py`: миграции выполняются под `pg_advisory_lock(703000)` на отдельном соединении.
|
||||
- `requirements.lock` (pip-compile), `pip-tools` в `requirements-dev.txt`; `.env.example`.
|
||||
- `README.md`: раздел «Публикация и эксплуатация» (пример Caddy, `TRUSTED_PROXIES`, обновление lock-файла).
|
||||
|
||||
## Проверка
|
||||
Проверка: стенд `ipam_control_006` пересобран, миграции применены до 0008 (`alembic check` — расхождений нет); ручная проверка (скрипт во временной папке, в репозиторий не добавлялся); автотесты по условию этапа не писались. Контейнер `healthy`, процесс `uid=10001(app)`, миграции 0005–0008 применены под блокировкой.
|
||||
Не проверено: одновременный запуск двух миграций; конфигурация Caddy (на хосте не менялась).
|
||||
@@ -0,0 +1,28 @@
|
||||
# Мелкие улучшения безопасности и журнала (изменение 023)
|
||||
|
||||
Находка ревью № 13, серьёзность — низкая. Пункты независимы, внедряются одним изменением.
|
||||
|
||||
## Context и решение
|
||||
1. **LIKE без экранирования.** `list_prefixes`, `list_users`, `_like()` в `refs.py` не экранируют `%` и `_` (в журнале это сделано — `_escape_like`).
|
||||
→ Перенести `_escape_like` в `app/services.py` как общий `like_pattern(q)` и использовать во всех поисках (`escape="\\"`).
|
||||
2. **Неудачные попытки очистки журнала не журналируются.** → В `clear_journal` при неверном пароле — запись `journal.clear_failed` (осталось попыток), при блокировке — `journal.clear_locked`.
|
||||
Бейджи в UI (`eventBadge`): `clear_failed`/`clear_locked` — красный.
|
||||
3. **Нет заголовков безопасности.** → В `RequestContextMiddleware` (или отдельном ASGI-middleware) добавлять к ответам:
|
||||
`Content-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'self'; frame-ancestors 'none'`, `X-Content-Type-Options: nosniff`,
|
||||
`Referrer-Policy: no-referrer`. Проверить, что UI не использует inline-скрипты/стили (в `app.js` есть атрибуты `style="…"` → для них нужен `style-src 'self' 'unsafe-inline'`;
|
||||
скрипты inline не используются). Swagger `/docs` грузит ресурсы с CDN → для путей `/docs`, `/redoc` CSP не ставится.
|
||||
4. **Удаление префикса с `force=true`** не фиксирует число удалённых адресов. → Подсчёт до удаления, `diff: {"force": true, "addresses_deleted": N}`.
|
||||
5. **Токены после смены пароля действуют до истечения.** → Колонка `users.password_changed_at` (миграция), в токен — `iat`; `current_user` отклоняет токены с `iat < password_changed_at`.
|
||||
Смена пароля самим пользователем выдаёт новый токен в ответе (`/users/me/password` → `{"ok": true, "access_token": …}`), UI сохраняет его — текущая сессия не прерывается.
|
||||
|
||||
## Файлы
|
||||
`app/services.py`, `app/api/v1/{refs,prefixes,users,journal,auth}.py`, `app/request_context.py`, `app/security.py`, `app/models.py`,
|
||||
`alembic/versions/<next>_password_changed_at.py`, `web/app.js`, `tests/test_api.py`, `tests/test_users.py`, `README.md`.
|
||||
|
||||
## Тесты (минимум)
|
||||
- Поиск организаций по `%` не возвращает все записи.
|
||||
- Смена пароля: старый токен → 401, новый из ответа → 200.
|
||||
- Заголовки: `GET /` содержит `Content-Security-Policy` и `X-Content-Type-Options`.
|
||||
|
||||
## Проверка
|
||||
`pytest -q`; в браузере (Playwright) — пройти все экраны UI без ошибок CSP в консоли; журнал — записи `journal.clear_failed`.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Итог: мелкие улучшения безопасности и журнала (изменение 023)
|
||||
|
||||
## Что сделано
|
||||
1. **LIKE:** `contains()`/`like_escape()` в `app/services.py`; все поиски (`refs`, `prefixes`, `users`, журнал) экранируют `%` и `_`.
|
||||
2. **Журнал очистки:** неверный пароль → `journal.clear_failed` (осталось попыток), блокировка → `journal.clear_locked`; красные бейджи в UI.
|
||||
3. **Заголовки безопасности** (`SecurityHeadersMiddleware` в `app/main.py`): `Content-Security-Policy` (`default-src 'self'`, `style-src 'self' 'unsafe-inline'` — UI использует атрибуты `style`, `frame-ancestors 'none'`), `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`; для `/docs`, `/redoc`, `/openapi.json` CSP не ставится (Swagger с CDN).
|
||||
4. **`force`-удаление префикса:** в журнале `diff = {"force": true, "addresses_deleted": N}`.
|
||||
5. **Отзыв токенов при смене пароля:** миграция `0005` (`users.password_changed_at`); в токене claim `pv` (версия пароля), `current_user` отклоняет токены с другой версией (401). Отклонение от плана: вместо сравнения `iat` — версия пароля (не зависит от точности часов).
|
||||
`POST /users/me/password` возвращает новый `access_token`, UI подхватывает его — текущая сессия не прерывается; сброс пароля администратором отзывает токены пользователя.
|
||||
- **Побочный эффект:** `tests/test_users.py::test_users_management` (строка «выданный ранее токен продолжает работать») теперь падает — поведение изменено намеренно; тест обновить на этапе ревью с тестами.
|
||||
- `README.md`: разделы API, «Пользователи и роли», «Журнал».
|
||||
|
||||
## Проверка
|
||||
Проверка: стенд `ipam_control_006` пересобран, миграции применены до 0008 (`alembic check` — расхождений нет); ручная проверка (скрипт во временной папке, в репозиторий не добавлялся); автотесты по условию этапа не писались. Заголовки на `/` есть, на `/docs` CSP нет; поиск `%` в организациях пуст; старый токен после смены пароля → 401, новый → 200; `addresses_deleted` в журнале. UI при этом не открывался в браузере — CSP проверить на этапе ревью (консоль браузера).
|
||||
@@ -0,0 +1,73 @@
|
||||
# Исправление находок ревью изменений 011–023 (изменение 024)
|
||||
|
||||
Источник: `docs/reviews/2026-09-26-changes-011-023-review.md`. Изменения 011–023 не закоммичены; правки вносятся поверх них в рабочем дереве.
|
||||
Новые автотесты не пишутся (отдельный этап). Исключение — п. 3: существующий тест приводится к намеренно изменённому поведению.
|
||||
|
||||
## Находки и решения
|
||||
|
||||
### 1. Параллельные запросы обходят лимит входа (средняя, 012)
|
||||
**Где:** `app/api/v1/auth.py`, `login`.
|
||||
**Суть:** проверка блокировки (`_retry_after`) идёт до проверки пароля, запись попытки — после. Параллельные запросы проходят проверку одновременно.
|
||||
Воспроизведено: 16 параллельных попыток → 16 проверок пароля при лимите 5.
|
||||
**Решение:** в начале `login`, до `_retry_after`, выполнить `db.execute(select(func.pg_advisory_xact_lock(func.hashtext(name))))`, где `name` — логин в нижнем регистре.
|
||||
Блокировка держится до конца транзакции, после `commit` снимается. Попытки одного логина сериализуются; лимит по IP при этом считается корректно для каждого логина.
|
||||
Остальную логику не менять.
|
||||
|
||||
### 2. Перенос адресов нарушает запрет адреса сети/broadcast (низкая, 011 × 015)
|
||||
**Где:** `app/api/v1/prefixes.py` — `create_prefix`, `_move_to_vrf`, `rehome_addresses`; `alembic/versions/0007_address_vrf_unique.py`.
|
||||
**Суть:** при создании вложенного префикса адреса родителя из его диапазона переносятся в него, и адрес может стать адресом сети или broadcast нового префикса.
|
||||
Воспроизведено: `.128` в `/24`, затем создание `.128/25` — адрес стал сетевым.
|
||||
**Решение:**
|
||||
- Функция `_unusable_after_rehome(db, p) -> list[str]`: адреса VRF префикса `p`, которые после переноса окажутся в префиксе, где они являются адресом сети или broadcast.
|
||||
Проверять самый узкий целевой префикс; для IPv4 с длиной ≤ /30 использовать `network_role` из `app/services.py`.
|
||||
- `create_prefix`: после `flush` и `attach_to_tree`, до `rehome_addresses`, при непустом списке — `db.rollback()` и 422:
|
||||
«Адреса … станут адресом сети/broadcast префикса X: освободите их или выберите другой префикс».
|
||||
- `_move_to_vrf`: та же проверка после смены VRF, до `rehome_addresses`, — 422 без частичных изменений (исключение внутри транзакции, `commit` не выполняется).
|
||||
- `allocate_subnet` не трогать: адреса родителя уже считаются занятыми.
|
||||
- Миграция 0007: после шага переноса адресов вывести предупреждение (`print` или `logging`) со списком адресов сети/broadcast в новых префиксах. Миграцию не останавливать.
|
||||
Уже применённую на стенде миграцию не переписывать по смыслу — только добавить вывод.
|
||||
|
||||
### 3. Устаревший тест токенов (низкая, 023)
|
||||
**Где:** `tests/test_users.py`, строка с комментарием «выданный ранее токен продолжает работать».
|
||||
**Решение:** привести тест к новой семантике.
|
||||
- Старый токен `other` после `POST /users/me/password` → 401.
|
||||
- Токен из ответа (`access_token`) → `GET /auth/me` 200.
|
||||
|
||||
Больше тесты не менять.
|
||||
|
||||
### 4. Полнота журнала входов (низкая, 012)
|
||||
**Где:** `app/api/v1/auth.py`, `app/rotation.py`.
|
||||
**Решение:**
|
||||
- Решение о записи `session.failed` принимать по числу неудач **этого логина** в окне (до вставки). Для этого `_retry_after` должна возвращать счётчики по областям отдельно,
|
||||
например `{"login": n, "ip": m}`, а не их максимум.
|
||||
- В `diff` записи `session.locked` с областью `ip` добавить `distinct_logins` — число различных логинов с этого IP в окне.
|
||||
- `rotate()`: первыми удалять записи `entity_type == "session"` с `action` в (`failed`, `locked`).
|
||||
|
||||
### 5. Сброс своего пароля через PATCH (низкая, 023)
|
||||
**Где:** `app/api/v1/users.py`, `update_user`.
|
||||
**Решение:** если `u.id == admin.id` и в запросе есть `password` — 422 «Свой пароль меняется через /users/me/password (с подтверждением текущего)».
|
||||
|
||||
### 6. Нет валидации PATCH устройства (низкая, найдено попутно)
|
||||
**Где:** `app/schemas.py`, `DeviceUpdate`.
|
||||
**Решение:** вынести проверки `DeviceIn._name` (FQDN) и `DeviceIn._mac` (формат и нормализация `AA:BB:…`) в модульные функции и применить их в `DeviceIn` и `DeviceUpdate`.
|
||||
В `DeviceUpdate` `None` пропускать; пустая строка для `mac` допустима, как в `DeviceIn`. Порядок с `_blank` (null → "") сохранить: сначала `_blank`, затем проверка.
|
||||
|
||||
### 7. Мелочи токена (инфо, 023)
|
||||
**Где:** `app/security.py`.
|
||||
**Решение:**
|
||||
- `_password_version`: целочисленный расчёт `(changed - datetime(1970, 1, 1, tzinfo=timezone.utc)) // timedelta(microseconds=1)`.
|
||||
- `iat` оставить как информационное поле: добавить комментарий, что отзыв работает по `pv`.
|
||||
|
||||
## Артефакты
|
||||
- `docs/changes/024-review-fixes-011-023/SUMMARY.md` — что сделано по каждому пункту, отклонения от плана, как проверено.
|
||||
- `README.md` — дополнить там, где меняется поведение:
|
||||
- 422 при создании префикса или переносе VRF;
|
||||
- запрет сброса своего пароля через PATCH;
|
||||
- валидация PATCH устройства.
|
||||
|
||||
## Проверка
|
||||
1. `venv/bin/python -c 'import app.main'` и `node --check web/app.js`.
|
||||
2. `docker compose -p ipam_control_006 up -d --build app` — только проект `ipam_control_006`; контейнеры прежней поставки `ipam_control-*` не трогать.
|
||||
3. `venv/bin/python -m pytest -q` — все тесты проходят.
|
||||
4. Ручная проверка через API (скрипт в scratchpad, не в репозитории) по п. 1, 2, 5, 6. Временные данные удалить.
|
||||
После проверки п. 1 очистить `login_attempts`, чтобы не блокировать вход.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Итог: исправление находок ревью изменений 011–023 (изменение 024)
|
||||
|
||||
Источник: `docs/reviews/2026-09-26-changes-011-023-review.md`, план: `PLAN.md` в этой же папке. Все 7 пунктов выполнены поверх незакоммиченных изменений 011–023 (не откатывались).
|
||||
|
||||
## Что сделано
|
||||
|
||||
### 1. Параллельные запросы обходят лимит входа (012)
|
||||
`app/api/v1/auth.py`, `login`: в начале обработчика — `pg_advisory_xact_lock(hashtext(логин_в_нижнем_регистре))`, до `_retry_after`. Блокировка держится до `commit`/закрытия сессии, попытки одного логина сериализуются; лимит по IP не тронут.
|
||||
|
||||
### 2. Перенос адресов может нарушить запрет адреса сети/broadcast (011 × 015)
|
||||
`app/api/v1/prefixes.py`: `_unusable_after_rehome(db, p)` — та же логика выбора «самого узкого целевого префикса», что и в `rehome_addresses`, но без переноса: возвращает пары `(адрес, CIDR целевого префикса)`, где адрес окажется сетевым/broadcast (`network_role`).
|
||||
- `create_prefix`: проверка после `attach_to_tree`, до `rehome_addresses`; при находках — `db.rollback()` и 422 с текстом и целевым префиксом на каждый адрес.
|
||||
- `_move_to_vrf`: та же проверка после смены VRF и `attach_to_tree`, до `rehome_addresses`; исключение до `commit()` — частичных изменений нет (сессия откатывается при закрытии).
|
||||
- `allocate_subnet` не тронут.
|
||||
- Миграция `0007`: после шага переноса адресов — предупреждение (`print`) со списком адресов, ставших сетевым/broadcast в новом префиксе (SQL-запрос через `network()`/`broadcast()`); миграция не останавливается. Сам перенос в 0007 не переписан.
|
||||
|
||||
**Отклонение от плана:** функция возвращает `list[tuple[str, str]]` (адрес + фактический целевой префикс), а не `list[str]`, как в тексте плана. Целевой префикс при переносе VRF не всегда совпадает с `p` (может быть уже существующий вложенный префикс целевого VRF) — сообщение вида «адрес X станет сетевым для p» было бы недостоверным; это подтвердилось на сценарии переноса при первом прогоне ручной проверки. Итоговое сообщение перечисляет каждый адрес с его настоящим целевым CIDR.
|
||||
|
||||
### 3. Устаревший тест токенов (023)
|
||||
`tests/test_users.py::test_users_management`: старый токен после `POST /users/me/password` → 401; токен из ответа (`access_token`) → `GET /auth/me` 200. Других тестов не касались.
|
||||
|
||||
### 4. Полнота журнала входов (012)
|
||||
`app/api/v1/auth.py`: `_retry_after` теперь возвращает счётчики по областям отдельно (`{"login": n, "ip": m}`), а не максимум. Решение о записи `session.failed` принимается по числу неудач именно этого логина (`before["login"] == 0`), а не по максимуму с IP. В `diff` записи `session.locked` с областью `ip` добавлено поле `distinct_logins` (число различных логинов с этого IP в окне, `_distinct_logins`).
|
||||
`app/rotation.py`: условие удаления «шумных» записей первыми при ротации по количеству расширено с `action == "failed"` до `action.in_(("failed", "locked"))`, чтобы `session.locked` тоже вытеснялся первым.
|
||||
|
||||
### 5. Сброс своего пароля через PATCH (023)
|
||||
`app/api/v1/users.py`, `update_user`: если `u.id == admin.id` и в теле запроса есть `password` — 422 «Свой пароль меняется через /users/me/password (с подтверждением текущего)». Проверка — до извлечения `pwd` из данных.
|
||||
|
||||
### 6. Нет валидации PATCH устройства (найдено попутно)
|
||||
`app/schemas.py`: `DeviceIn._name`/`_mac` вынесены в модульные функции `_device_name`/`_device_mac` (обе принимают `None` и пропускают его — для `DeviceIn` это неактуально, так как поля обязательны). Применены в `DeviceIn` и `DeviceUpdate` через тот же идиом, что и `_blank` (`field_validator(...)( _device_name )`). В `DeviceUpdate` порядок сохранён: сначала `_blank_text` (null → "" для `mac`/`note`), затем проверка формата; пустая строка для `mac` допустима, как в `DeviceIn`.
|
||||
|
||||
### 7. Мелочи токена (023)
|
||||
`app/security.py`: `_password_version` считает целочисленно — `(changed - EPOCH) // timedelta(microseconds=1)`, без `float`. У `iat` в `create_token` добавлен комментарий: поле информационное, отзыв токенов работает по `pv`.
|
||||
|
||||
## Изменённые файлы
|
||||
`app/api/v1/auth.py`, `app/api/v1/prefixes.py`, `app/api/v1/users.py`, `app/schemas.py`, `app/security.py`, `app/rotation.py`, `alembic/versions/0007_address_vrf_unique.py`, `tests/test_users.py`, `README.md`.
|
||||
|
||||
## Проверка
|
||||
1. `venv/bin/python -c 'import app.main'` — успешно; `node --check web/app.js` — успешно.
|
||||
2. `docker compose -p ipam_control_006 up -d --build app` — образ пересобран, `ipam_control_006-app-1` в статусе `healthy`; `ipam_control_006-db-1` не тронут, миграция `0007` повторно не выполнялась (была применена ранее; добавленный `print` сработает при применении «с нуля»).
|
||||
3. `venv/bin/python -m pytest -q` → `14 passed`.
|
||||
4. Ручная проверка скриптом во временной папке (не в репозитории), учётка администратора из `.env`, порт `APP_PORT`:
|
||||
- **п.1**: 16 параллельных неверных входов для временного пользователя (`rv-brute-*`) → 4×401, 12×429, в `login_attempts` ровно 5 строк (только первые 5 попыток проверяют пароль — сериализация advisory-lock работает). `login_attempts` очищен после проверки.
|
||||
- **п.2**: `.128` в `/24` → создание вложенного `.128/25` → 422 (адрес указан вместе с целевым `198.51.100.128/25`); перенос `/24` с адресом `.128` в VRF, где уже есть `203.0.113.128/25`, → 422 (адрес указан вместе с целевым `203.0.113.128/25`). Временные организация/VRF/префиксы удалены.
|
||||
- **п.5**: `PATCH /users/{id}` на себя с `password` → 422 с текстом плана.
|
||||
- **п.6**: `PATCH /devices/{id}` с `name="bad name!"` → 422; `mac="zz"` → 422; `mac="aa-bb-cc-dd-ee-ff"` → 200, `mac` в ответе `"AA:BB:CC:DD:EE:FF"`. Временные устройство/тип устройства/организация удалены.
|
||||
Все четыре пункта — **PASS**.
|
||||
|
||||
## Не проверено
|
||||
- Снятие блокировки входа по истечении 10 минут и лимит по IP (20 попыток) — вне объёма ручной проверки этого этапа.
|
||||
- Полное содержимое `session.locked.diff.distinct_logins` при переборе нескольких разных логинов с одного IP (сериализация по логину проверена; сценарий «шумного IP» с несколькими логинами вручную не воспроизводился).
|
||||
- Повторное применение миграции `0007` «с нуля» (на новой базе) с реальными адресами сети/broadcast — предупреждение добавлено в код, но не исполнялось (на стенде миграция уже была применена ранее).
|
||||
|
||||
## Сверка (независимая проверка после внедрения)
|
||||
Проверено повторно на стенде `ipam_control_006`, без участия исполнителя: `pytest -q` — 14 passed; контейнер `healthy`; прежняя поставка `ipam_control-*` не тронута.
|
||||
- П.1: 16 параллельных неверных входов → проверено ровно 5 паролей (4×401, остальные 429) — PASS.
|
||||
- П.2: вложенный `.128/25` при адресе `.128` у родителя → 422, префикс не создан (откат); обычный вложенный `/25` создаётся и забирает `.5` (регрессии нет) — PASS.
|
||||
- П.3: тест токенов обновлён, весь набор проходит — PASS.
|
||||
- П.4: по коду — счётчики по областям раздельно, `distinct_logins`, ротация удаляет `failed` и `locked` первыми — соответствует плану (поведение «шумного IP» не прогонялось).
|
||||
- П.5: свой пароль через PATCH → 422, чужой → 200 — PASS.
|
||||
- П.6: `name="bad name!"`/`mac="zz"` → 422, `aa-bb-…` → `AA:BB:…`, `mac: null` → `""` — PASS.
|
||||
- П.7: целочисленная версия пароля, комментарий к `iat` — соответствует плану.
|
||||
|
||||
Остаточные замечания (не блокируют):
|
||||
- Лимит по IP не сериализуется между разными логинами: параллельный перебор многих логинов с одного IP может немного превысить 20 попыток.
|
||||
- `_unusable_after_rehome` проверяет все адреса диапазона, включая уже лежащие в своём самом узком префиксе: адрес сети/broadcast, внесённый до изменения 015, заблокирует создание охватывающего префикса (найти такие — `scripts/find_unusable_addresses.py`, на стенде 0).
|
||||
- Смена формулы `pv` (float → целое) может однократно разлогинить пользователей, у которых `password_changed_at` уже был задан (на стенде — только временные пользователи проверки).
|
||||
@@ -0,0 +1,107 @@
|
||||
# Ревью изменений 011–023 (доработки по ревью кодовой базы) — 2026-09-26
|
||||
|
||||
**Объём:** незакоммиченные изменения рабочего дерева поверх `cd09ef0`: 19 файлов, +514/−149, миграции `0005`–`0008`, скрипты `find_duplicate_addresses.py` и `find_unusable_addresses.py`, `requirements.lock`.
|
||||
**Метод:** чтение diff. Подозрительные места проверены на стенде `ipam_control_006` на временных данных, после проверки данные удалены. Все экраны UI открыты в headless Chromium под новым CSP. Выполнены `alembic check` и `pytest`.
|
||||
|
||||
Пометки: ✅ — воспроизведено на стенде, 🔎 — вывод по коду.
|
||||
|
||||
## Итог
|
||||
|
||||
Изменения в целом соответствуют планам и работают. Подтверждено на стенде:
|
||||
- `alembic check` без расхождений, миграции 0005–0008 применены;
|
||||
- контейнер работает от `uid=10001` и в статусе `healthy`;
|
||||
- под CSP все экраны UI открываются без ошибок в консоли, шрифты загружаются;
|
||||
- перенос префикса в другой VRF каскадно обновляет `addresses.vrf_id`;
|
||||
- поиск по `%`, отзыв токенов, ограничение попыток входа и границы пагинации работают;
|
||||
- тесты: 13 из 14 проходят.
|
||||
|
||||
**Одна существенная находка:** лимит попыток входа обходится параллельными запросами (№ 1). Ещё одно расхождение между 011 и 015 и несколько мелких замечаний перечислены ниже.
|
||||
|
||||
| # | Серьёзность | Изменение | Кратко |
|
||||
|---|---|---|---|
|
||||
| 1 | Средняя | 012 | Параллельные запросы обходят лимит входа: 16 одновременных попыток проверили 16 паролей при лимите 5 ✅ |
|
||||
| 2 | Низкая | 011 × 015 | Перенос адресов в новый вложенный префикс может сделать адрес его адресом сети/broadcast ✅ |
|
||||
| 3 | Низкая | 023 | `tests/test_users.py` падает: тест ожидает прежнее поведение токенов после смены пароля ✅ |
|
||||
| 4 | Низкая | 012 | Журнал входов: первые неудачи по новым логинам с «шумного» IP не пишутся; `session.locked` не вытесняется первым при ротации 🔎 |
|
||||
| 5 | Низкая | 023 | Сброс администратором **своего** пароля через `PATCH /users/{id}` завершает его сессию без выдачи нового токена 🔎 |
|
||||
| 6 | Низкая (выявлено попутно) | — | `PATCH /devices/{id}` не проверяет `name` и `mac` (принимает `"bad name!"`, `"zz"`) ✅ |
|
||||
| 7 | Инфо | 023 | Claim `iat` пишется, но не используется; версия пароля считается через `float` 🔎 |
|
||||
|
||||
---
|
||||
|
||||
## Находки
|
||||
|
||||
### 1. Параллельные запросы обходят лимит входа ✅ (012)
|
||||
В `login` (`app/api/v1/auth.py`) шаги идут в таком порядке:
|
||||
1. `_retry_after` проверяет блокировку;
|
||||
2. проверяется пароль (argon2, около 50–100 мс);
|
||||
3. попытка записывается;
|
||||
4. блокировка проверяется повторно.
|
||||
|
||||
Параллельные запросы проходят первую проверку до того, как любая из них запишет попытку, поэтому пароль проверяется в каждой.
|
||||
**Воспроизведение:** 16 одновременных запросов с неверным паролем для одного логина. Ответы: 4 × 401 и 12 × 429, но в `login_attempts` 16 записей, то есть проверено 16 паролей. Если бы один из них был верным, запрос вернул бы токен: ветка успеха не смотрит на счётчик. Лимит «5 за 10 минут» фактически превращается в «5 + степень параллелизма».
|
||||
**Исправление (любое из двух):**
|
||||
- сериализовать попытки по логину: `SELECT pg_advisory_xact_lock(hashtext(:login))` в начале обработчика, до `_retry_after`;
|
||||
- или записывать попытку **до** проверки пароля (пессимистично), а при успехе удалять её вместе с остальными. Тогда параллельные запросы видят растущий счётчик.
|
||||
|
||||
Первый вариант проще и не меняет семантику журнала.
|
||||
|
||||
### 2. Новый вложенный префикс может получить адрес сети или broadcast ✅ (011 × 015)
|
||||
`rehome_addresses()` переносит адреса родителя в самый узкий префикс, не проверяя правило из 015. То же делает шаг переноса в миграции `0007`.
|
||||
**Воспроизведение:** в `/24` назначен `198.51.100.128`, затем создан `198.51.100.128/25`. Адрес переехал в дочерний префикс и стал его адресом сети, хотя назначить его туда напрямую нельзя (422).
|
||||
**Исправление:** в `create_prefix` и при переносе VRF (`_move_to_vrf`) отклонять операцию (422 или 409), если среди адресов, которые перейдут в новый префикс, есть его адрес сети или broadcast, и перечислять такие адреса. `allocate_subnet` это не затрагивает: он уже считает адреса родителя занятыми. В миграции 0007 такие случаи выводить в лог как предупреждение (`find_unusable_addresses.py` их найдёт).
|
||||
|
||||
### 3. Устаревший тест токенов ✅ (023)
|
||||
`tests/test_users.py::test_users_management`, строка 55: `other.get("/auth/me").status_code == 200 # выданный ранее токен продолжает работать`. После 023 ответ 401. Поведение изменено намеренно (отзыв токенов при смене пароля), тест нужно привести к новой семантике:
|
||||
- старый токен даёт 401;
|
||||
- токен из ответа `POST /users/me/password` работает.
|
||||
|
||||
### 4. Полнота журнала входов 🔎 (012)
|
||||
- В журнал пишется «первая неудача в окне». Счётчик `before` берётся как максимум по логину и по IP, поэтому если с этого IP уже были неудачи по другим логинам, первая неудача по **новому** логину не попадёт в журнал. При переборе логинов с одного IP в журнале останется одна запись `session.failed` и затем `session.locked` с областью `ip`. Число и список логинов из записей не восстановить.
|
||||
**Исправление:** считать `before` отдельно по логину для решения о `session.failed`, а в `diff` записи `session.locked` по IP класть число различных логинов.
|
||||
- При ротации первыми удаляются только `session.failed`. Записи `session.locked` тоже порождаются анонимными клиентами и вытесняют события данных.
|
||||
**Исправление:** добавить `locked` в список удаляемых первыми.
|
||||
|
||||
### 5. Сброс своего пароля через PATCH 🔎 (023)
|
||||
`PATCH /users/{id}` с `password` для собственной учётной записи меняет `password_changed_at` и тем самым отзывает текущий токен администратора, но нового токена в ответе нет. UI такой сценарий не допускает (для своей записи поле пароля скрыто), а клиент API окажется разлогинен.
|
||||
**Исправление:** для `id == admin.id` отклонять `password` с 422 «Используйте /users/me/password» (проверка текущего пароля там обязательна), либо возвращать новый токен.
|
||||
|
||||
### 6. Нет валидации `PATCH /devices/{id}` ✅ (выявлено попутно)
|
||||
`DeviceUpdate` не проверяет `name` (FQDN) и `mac` (формат, нормализация), хотя `DeviceIn` проверяет. PATCH с `name="bad name!"` и `mac="zz"` возвращает 200. Проблема существовала и до 011–023, но изменение 018 затронуло эту схему.
|
||||
**Исправление:** переиспользовать валидаторы `DeviceIn._name` и `DeviceIn._mac` в `DeviceUpdate`, пропуская `None`.
|
||||
|
||||
### 7. Мелочи 🔎 (023)
|
||||
- `iat` добавлен в токен, но нигде не проверяется. Отзыв работает по `pv`, поэтому `iat` можно оставить как информационное поле или убрать.
|
||||
- `_password_version` считает `int(changed.timestamp() * 1_000_000)` через `float`. Результат детерминирован, так как значение в сессии и в БД совпадает до микросекунды, но надёжнее считать целочисленно: `(changed - EPOCH) // timedelta(microseconds=1)`.
|
||||
|
||||
---
|
||||
|
||||
## Соответствие планам
|
||||
|
||||
| Изменение | Статус | Замечания |
|
||||
|---|---|---|
|
||||
| 011 уникальность IP в VRF | ✅ с замечанием | № 2; перенос в VRF и каскад `vrf_id` проверены |
|
||||
| 012 лимит входа | ⚠️ | № 1 (обход параллелизмом), № 4 |
|
||||
| 013 границы пагинации | ✅ | Вместо общей зависимости используется константа `MAX_OFFSET` (отражено в SUMMARY) |
|
||||
| 014 экран адресов | ✅ | IPv6 `offset=9·10⁶` отвечает за 0,02 с |
|
||||
| 015 адрес сети/broadcast | ✅ с замечанием | Обход через перенос адресов (№ 2) |
|
||||
| 016 роль по умолчанию | ✅ | — |
|
||||
| 017 проверка секретов | ✅ | — |
|
||||
| 018 null в PATCH | ✅ | Попутно № 6 |
|
||||
| 019 N+1 | ✅ частично | «Обзор» не переписан (отражено в SUMMARY) |
|
||||
| 020 автоназначение адреса | ✅ | Параллельный сценарий не проверялся |
|
||||
| 021 блокировки | ✅ | Параллельное отключение двух администраторов не проверялось |
|
||||
| 022 эксплуатация | ✅ | `APP_BIND` по умолчанию `0.0.0.0` (сознательно, отражено в SUMMARY) |
|
||||
| 023 мелкие улучшения | ✅ с замечаниями | № 3, 5, 7; CSP проверен в браузере |
|
||||
|
||||
## Не проверено (для этапа тестов)
|
||||
- Остановка миграции 0007 на реальном дубле и миграции 0008 на логинах, совпадающих без учёта регистра.
|
||||
- Снятие блокировки входа через 10 минут; лимит по IP (20 попыток).
|
||||
- Параллельные сценарии 020 (разные адреса) и 021 (двое администраторов отключают друг друга).
|
||||
- Одновременный запуск двух `alembic upgrade head`.
|
||||
|
||||
## Рекомендуемые действия до коммита
|
||||
1. Исправить № 1: advisory-lock по логину в `login`.
|
||||
2. Исправить № 2: проверка адреса сети и broadcast при переносе адресов в новый префикс.
|
||||
3. Обновить тест из № 3 на этапе тестов.
|
||||
4. № 4–7 — по желанию, вместе с этапом тестов.
|
||||
@@ -0,0 +1,160 @@
|
||||
# Ревью кодовой базы IPAM Manager — 2026-09-26
|
||||
|
||||
**Объём:** `app/` (FastAPI, SQLAlchemy, ~1 700 строк), `web/app.js` (~1 000 строк), `alembic/`, `tests/`, Docker-окружение.
|
||||
**Состояние:** коммит `cd09ef0` (задачи 001–010).
|
||||
**Метод:** чтение кода. Подозрительные места проверены запросами к стенду `ipam_control_006` на временных данных, которые потом удалены. Выполнен `alembic check`.
|
||||
|
||||
Пометки: ✅ — воспроизведено на стенде, 🔎 — вывод по коду.
|
||||
|
||||
## Итог
|
||||
|
||||
Кодовая база компактная и последовательная. Бизнес-правила собраны в API. Журнал аудита сделан добротно: ротация под advisory-lock, IP клиента с учётом доверенных прокси. В UI все данные сервера экранируются через `esc()`, XSS-векторов не найдено. Миграции совпадают с моделями (`alembic check`: «No new upgrade operations detected»). Все 14 тестов проходят.
|
||||
|
||||
Основные риски:
|
||||
- **Целостность адресного пространства.** Один IP можно завести дважды в одном VRF. Можно назначить адрес сети и broadcast.
|
||||
- **Устойчивость API.** Отрицательные `limit`/`offset` дают 500. У `offset` на экране адресов нет верхней границы.
|
||||
- **Журнал можно вытеснить без авторизации.** Неудачные входы пишутся без ограничений, ротация по количеству удаляет старые записи.
|
||||
|
||||
| # | Серьёзность | Область | Кратко |
|
||||
|---|---|---|---|
|
||||
| 1 | Высокая | Данные | Один IP дважды в VRF (в родителе и в дочернем префиксе); занятость завышается ✅ |
|
||||
| 2 | Высокая | Безопасность | Анонимный перебор паролей без ограничений и вытеснение журнала аудита 🔎 |
|
||||
| 3 | Средняя | API | Отрицательные `limit`/`offset` дают 500 ✅ |
|
||||
| 4 | Средняя | Производительность / DoS | Экран адресов: неограниченный `offset` для `status=free` и выборка без LIMIT 🔎 |
|
||||
| 5 | Средняя | Данные | Можно назначить адрес сети и broadcast; `free` и загрузка считаются неверно ✅ |
|
||||
| 6 | Средняя | Безопасность | Роль по умолчанию в `POST /users` — `admin` ✅ |
|
||||
| 7 | Средняя | Безопасность | Встроенный `jwt_secret = "dev-only-secret"` без проверки при старте 🔎 |
|
||||
| 8 | Низкая | API | `null` в PATCH адреса даёт 409 «Запись … уже существует» ✅ |
|
||||
| 9 | Низкая | Производительность | N+1 запросов в списках устройств, операторов, VRF, типов и на «Обзоре» 🔎 |
|
||||
| 10 | Низкая | Данные | `next_free` (автоназначение адреса) не учитывает вложенные префиксы 🔎 |
|
||||
| 11 | Низкая | Конкурентность | Нет блокировок в проверке «последний администратор» и в `allocate_next` 🔎 |
|
||||
| 12 | Низкая | Эксплуатация | Контейнер работает от root, нет healthcheck у `app`, миграции выполняются при старте каждой реплики 🔎 |
|
||||
| 13 | Низкая | Мелочи | LIKE без экранирования, нет журналирования неудачных попыток очистки журнала, нет заголовков безопасности 🔎 |
|
||||
|
||||
---
|
||||
|
||||
## Находки
|
||||
|
||||
### 1. Один IP-адрес дважды в VRF ✅
|
||||
`create_address` (`app/api/v1/prefixes.py:314`) проверяет две вещи: адрес входит в префикс и уникальна пара `(prefix_id, address)` (`app/models.py:119`). Уникальности адреса в пределах VRF нет. Кроме того, адрес можно записать на родителя, даже если он попадает в диапазон дочернего префикса.
|
||||
**Воспроизведение:** родитель `198.51.100.0/24`, дочерний `/25`. `198.51.100.5` создаётся и в родителе, и в дочернем (оба ответа 201). У родителя `used = 4` при трёх уникальных адресах.
|
||||
**Последствия:** дубли в реестре. `_usage` суммирует адреса поддерева, поэтому загрузка и «Обзор» завышаются.
|
||||
**Рекомендация:**
|
||||
- Хранить адреса только в самом узком префиксе. При создании проверять, что адрес не попадает ни в один дочерний префикс.
|
||||
- Добавить уникальность `(vrf_id, address)`: денормализовать `vrf_id` в `addresses` или поставить триггер. Иначе инвариант держится только в коде.
|
||||
- При создании префикса (`attach_to_tree`) переносить в новый дочерний адреса родителя из его диапазона.
|
||||
|
||||
### 2. Перебор паролей и вытеснение журнала без авторизации 🔎
|
||||
`POST /auth/login` (`app/api/v1/auth.py:14`) никак не ограничен. Каждая неудача пишет `session.failed` в `audit_log`. Ротация по количеству (`app/rotation.py:42`) удаляет самые старые записи сверх `max_entries` (по умолчанию 100 000).
|
||||
**Последствия:**
|
||||
- неограниченный перебор паролей;
|
||||
- анонимный клиент может за несколько минут вытеснить из журнала историю реальных изменений.
|
||||
|
||||
Попутно: при несуществующем логине argon2 не вызывается, поэтому по времени ответа можно понять, существует ли логин. У `LoginIn` нет ограничения длины пароля.
|
||||
**Рекомендация:**
|
||||
- ограничить частоту попыток по IP и логину (таблица попыток по образцу `ClearAttempt` или прокси);
|
||||
- при серии неудач агрегировать их в одну запись журнала;
|
||||
- выполнять фиктивную проверку argon2 для несуществующего логина;
|
||||
- ограничить `password` до `max_length=128`;
|
||||
- рассмотреть отдельную квоту ротации для событий `session.*`.
|
||||
|
||||
### 3. Отрицательные `limit`/`offset` дают 500 ✅
|
||||
У всех списков объявлено `limit: int = Query(…, le=…)` и `offset: int = 0` без нижней границы. `GET /audit?limit=-1` и `GET /isps?offset=-1` возвращают **500**: PostgreSQL отвергает отрицательный LIMIT/OFFSET.
|
||||
**Рекомендация:** `Query(…, ge=1, le=…)` и `Query(0, ge=0)` во всех списках (`refs.py`, `prefixes.py`, `journal.py`, `users.py`). Удобно завести общую зависимость пагинации.
|
||||
|
||||
### 4. `GET /prefixes/{id}/addresses`: ресурсоёмкие ветки 🔎
|
||||
В `list_addresses` (`app/api/v1/prefixes.py:272`) две проблемы.
|
||||
- При `status=free` цикл собирает `offset + limit` свободных адресов в список (`need = offset + limit`). У `offset` нет верхней границы. На IPv6-префиксе любой пользователь с ролью `viewer` одним запросом с `offset=10^8` занимает процессор и память воркера.
|
||||
- Без фильтров основной запрос (строка 289) выполняется **без LIMIT/OFFSET**: все адреса префикса загружаются и нарезаются в Python. На крупных подсетях это десятки тысяч объектов на каждую страницу.
|
||||
|
||||
**Рекомендация:**
|
||||
- для `free` генерировать нужную страницу арифметически, пропуская занятые диапазоны, без материализации `offset` элементов, и ограничить `offset`;
|
||||
- в ветке без свободных адресов перенести пагинацию в SQL;
|
||||
- смешанный режим (занятые + свободные) оставить только для подсетей ≤ `FREE_LISTING_LIMIT`, как сейчас.
|
||||
|
||||
### 5. Назначаются адрес сети и broadcast ✅
|
||||
`create_address` проверяет только `ip in network`. В `198.51.100.0/25` успешно назначаются `.0` и `.127`. При этом `capacity()` для IPv4 до `/30` вычитает эти два адреса. В итоге `free = cap - stored` занижается, а загрузка может превысить 100 %.
|
||||
**Рекомендация:** для IPv4 с длиной ≤ 30 отклонять адрес сети и broadcast (422). Вариант — считать их занятыми только в `capacity`, но это хуже.
|
||||
|
||||
### 6. Роль по умолчанию — администратор ✅
|
||||
`UserIn.role` по умолчанию `Role.admin` (`app/schemas.py:79`), и `POST /users` без `role` создаёт администратора. UI передаёт `viewer` явно, но у клиентов API и скриптов по умолчанию наибольшие права.
|
||||
Та же картина в модели: `User.role` — `default=Role.admin`.
|
||||
**Рекомендация:** по умолчанию `viewer`.
|
||||
|
||||
### 7. Встроенный JWT-секрет 🔎
|
||||
`Settings.jwt_secret = "dev-only-secret"` (`app/config.py:8`). Если приложение запущено без `JWT_SECRET` (локально или другим способом развёртывания), токены подписываются общеизвестным ключом, и их можно подделать для любого логина.
|
||||
В docker-compose пустое значение даёт не небезопасный режим, а 500 при входе: PyJWT отвергает пустой HMAC-ключ.
|
||||
**Рекомендация:** убрать значение по умолчанию. При старте проверять длину секрета, например ≥ 32 байт, и не запускаться без него. То же для `ADMIN_PASSWORD` при пустой БД: сейчас без него админ молча не создаётся.
|
||||
|
||||
### 8. `null` в `PATCH /addresses/{id}` даёт ложный 409 ✅
|
||||
`update_address` использует `model_dump(exclude_unset=True)` без `exclude_none`, поэтому `{"description": null}` пишет NULL в колонку NOT NULL. Приходит `IntegrityError`, и ответ — 409 «Запись с такими значениями уже существует».
|
||||
Остальные PATCH-эндпоинты используют `exclude_none=True`.
|
||||
**Рекомендация:** `exclude_none=True`. Если нужна очистка поля, явно приводить `None` к `""`.
|
||||
|
||||
### 9. N+1 запросов 🔎
|
||||
Счётчики и связанные объекты догружаются отдельными запросами на каждую строку:
|
||||
- `_device_out`: 2 запроса на устройство, до 1 000 на странице из 500;
|
||||
- `_isp_out`: организация на каждого оператора;
|
||||
- `_vrf_out`, `_type_out`, `_org_out`: счётчики на каждую строку;
|
||||
- `_prefix_outs` на «Обзоре»: `_depths` и `_capacities` по каждой организации.
|
||||
|
||||
При текущих объёмах это незаметно, но растёт линейно.
|
||||
**Рекомендация:** агрегаты одним `GROUP BY` на страницу и `selectinload` для связей.
|
||||
|
||||
### 10. Автоназначение адреса не учитывает вложенные префиксы 🔎
|
||||
`next_free` (`app/services.py:309`) исключает только адреса самого пула. Если внутри пула есть дочерний префикс, выданный адрес может попасть в его диапазон. Это частный случай п. 1.
|
||||
Кроме того, функция перебирает хосты подряд и загружает в память все занятые адреса. Для крупных пулов лучше использовать тот же приём, что и в `next_free_subnet` (перескок за занятые диапазоны).
|
||||
|
||||
### 11. Гонки без блокировок 🔎
|
||||
- **Последний администратор.** `update_user` и `delete_user` проверяют «последний активный администратор» через `count()` без блокировки. Два администратора, одновременно отключающие друг друга, могут оставить систему без админа. Нужен `SELECT … FOR UPDATE` по активным администраторам или advisory-lock.
|
||||
- **Автоназначение адреса.** В `allocate_next` при параллельных запросах один из них получает 409 «повторите запрос». Корректно, но можно блокировать префикс так же, как в `allocate_subnet`.
|
||||
- **Логин без учёта регистра.** Уникальность проверяется запросом, а в БД `username` уникален с учётом регистра. Нужен индекс `unique(lower(username))`, как у VRF.
|
||||
|
||||
### 12. Эксплуатация 🔎
|
||||
- `Dockerfile`: процесс работает от root. Нет `USER`, нет `HEALTHCHECK`, у сервиса `app` в compose нет `healthcheck`, хотя `/healthz` есть.
|
||||
- `alembic upgrade head` в `CMD` выполняется при старте каждой реплики. При масштабировании нужен отдельный job или advisory-lock в `env.py`.
|
||||
- Приложение слушает `0.0.0.0:8088` по HTTP, JWT и пароли идут открытым текстом по LAN. Для эксплуатации нужен TLS-прокси (Caddy на хосте уже есть) и `TRUSTED_PROXIES`.
|
||||
- Зависимости заданы диапазонами без lock-файла, поэтому сборки не воспроизводимы.
|
||||
|
||||
### 13. Мелочи 🔎
|
||||
- **LIKE без экранирования.** В `list_prefixes`, `list_users` и `_like()` (`refs.py`) `%` и `_` в поиске не экранируются (в журнале это сделано, `_escape_like`).
|
||||
- **Очистка журнала.** Неудачные попытки подтверждения пароля пишутся только в `clear_attempts`, в журнал они не попадают, хотя событие значимо для безопасности.
|
||||
- **Ответы без заголовков безопасности.** Нет `Content-Security-Policy`, `X-Frame-Options`, `X-Content-Type-Options`, поэтому UI можно встроить во фрейм.
|
||||
- **Удаление префикса с `force=true`.** Запись журнала не содержит числа удалённых вместе с ним адресов.
|
||||
- **Хранение токена.** Токен лежит в `sessionStorage`: при XSS его можно украсть. XSS сейчас не найден, но CSP снизил бы риск.
|
||||
- **Смена пароля.** Уже выданные токены не отзываются (задокументировано). Можно хранить `password_changed_at` и сверять с `iat` токена.
|
||||
|
||||
---
|
||||
|
||||
## Что сделано хорошо
|
||||
- **Целостность VRF и организации.** Обеспечена в БД: составной FK `fk_prefixes_vrf_org`, уникальный индекс `lower(name)`.
|
||||
- **Ошибки API.** Единый формат `{"code", "message", "fields"}`. Нарушения ограничений БД переводятся в 409, а не в 500.
|
||||
- **Журнал.** Ротация под `pg_try_advisory_xact_lock`. IP берётся из `X-Forwarded-For` только от доверенных прокси, разбор идёт справа налево. Мусор в заголовке игнорируется.
|
||||
- **Пароли.** Хранятся в argon2. Очистка журнала подтверждается паролем с блокировкой после пяти неудач. Отключение учётной записи действует немедленно.
|
||||
- **UI без сборки и зависимостей.** Всё, что приходит с сервера, экранируется, `innerHTML` используется только с экранированным содержимым.
|
||||
- **Тесты интеграционные, по реальному стеку.** Они короткие и проверяют бизнес-правила, а не реализацию.
|
||||
|
||||
## Планы доработок
|
||||
Одна находка — один план в `docs/changes/`:
|
||||
|
||||
| № | План |
|
||||
|---|---|
|
||||
| 1 | `011-address-unique-in-vrf` |
|
||||
| 2 | `012-login-rate-limit` |
|
||||
| 3 | `013-pagination-bounds` |
|
||||
| 4 | `014-addresses-listing-performance` |
|
||||
| 5 | `015-network-broadcast-addresses` |
|
||||
| 6 | `016-default-role-viewer` |
|
||||
| 7 | `017-jwt-secret-validation` |
|
||||
| 8 | `018-patch-null-handling` |
|
||||
| 9 | `019-n-plus-one-queries` |
|
||||
| 10 | `020-next-free-address` (после 011) |
|
||||
| 11 | `021-concurrency-locks` |
|
||||
| 12 | `022-ops-hardening` |
|
||||
| 13 | `023-minor-hardening` |
|
||||
|
||||
## Предлагаемый порядок работ
|
||||
1. **Быстрые правки, около часа, без миграций:** пп. 3, 5, 6, 8, экранирование LIKE из п. 13.
|
||||
2. **Безопасность:** п. 2 (ограничение частоты входа, агрегирование неудач в журнале), п. 7 (проверка секрета при старте), п. 12 (non-root, healthcheck, TLS через прокси).
|
||||
3. **Целостность данных:** пп. 1 и 10 — модель «адрес в самом узком префиксе» и уникальность в VRF. Нужны миграция и проверка существующих дублей.
|
||||
4. **Производительность:** пп. 4 и 9, когда объёмы станут заметными. Ограничение `offset` из п. 4 стоит сделать сразу вместе с п. 3.
|
||||
Reference in new issue
Block a user