Files
ayurishchevandClaude Opus 5.5 13e17fbb47 Задачи 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>
2026-09-26 21:33:50 +03:00

11 KiB
Raw Permalink Blame History

Итог: исправление находок ревью изменений 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 уже был задан (на стенде — только временные пользователи проверки).