README: актуализация и оптимизация

Текст описывает текущее состояние проекта (конфигурация, архитектура, правила данных, API,
безопасность, эксплуатация, UI, тесты) без описаний отдельных доработок; история изменений
001-030 и отчёты ревью вынесены в таблицы со ссылками на docs/changes и docs/reviews.
Исправлены устаревшие сведения: структура app/, миграции 0001-0009, команда тестов
с проектом ipam_control_006, срок жизни токена.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ayurishchevandClaude Opus 5.5 committed 2026-09-27 08:44:17 +03:00
1 parent 03d727e496
commit 5210ba3333
1 file changed
+141 -102
+141 -102
View File
@@ -1,134 +1,173 @@
# IPAM Manager # IPAM Manager
Реестр IP-адресов и адресных префиксов в разрезе организаций. API-first: backend на Python (FastAPI) с PostgreSQL, Реестр IP-адресов и адресных префиксов в разрезе организаций. Проект построен по принципу API-first: backend на FastAPI и PostgreSQL,
UI-админка — отдельный лёгкий SPA (`web/`, без сборки), который только визуализирует ответы API. UI-админка — лёгкий SPA без сборки (`web/`), который только отображает ответы API. Макеты — страница «IPAM Manager» дизайн-канваса ros_control.
Макеты: страница «IPAM Manager» дизайн-канваса ros_control.
## Быстрый старт ## Быстрый старт
```bash ```bash
python3 scripts/gen_env.py # .env со случайными паролями/секретами (в .gitignore) python3 scripts/gen_env.py # .env со случайными паролями и секретами (файл в .gitignore)
docker compose up -d --build # postgres + app; миграции применяются автоматически docker compose up -d --build # postgres + app; миграции применяются при старте
python3 -m venv venv && venv/bin/pip install -r requirements-dev.txt python3 -m venv venv && venv/bin/pip install -r requirements-dev.txt
venv/bin/python scripts/seed_demo.py # (по желанию) демо-данные из макетов venv/bin/python scripts/seed_demo.py # по желанию: демо-данные из макетов
``` ```
- UI: http://127.0.0.1:8088/ · Swagger: http://127.0.0.1:8088/docs (порт приложения — `APP_PORT` в `.env`, слушает 0.0.0.0; порт БД — только 127.0.0.1) - UI: `http://<хост>:8088/`, Swagger: `http://<хост>:8088/docs`.
- Логин/пароль администратора — `ADMIN_USERNAME` / `ADMIN_PASSWORD` из `.env` (создаётся при первом старте; пароль не короче 8 символов). - Вход: `ADMIN_USERNAME` / `ADMIN_PASSWORD` из `.env`. Администратор создаётся при первом старте на пустой БД.
- `JWT_SECRET` обязателен и не короче 32 символов (заглушки отклоняются): без него приложение не стартует (изменение 017). `scripts/gen_env.py` генерирует корректные значения.
- Порт приложения публикуется на `${APP_BIND:-0.0.0.0}` (в `.env` можно задать `APP_BIND=127.0.0.1` — только loopback, за reverse-proxy с TLS, см. «Публикация»). ## Конфигурация (`.env`)
| Переменная | Назначение |
|---|---|
| `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD` | База данных |
| `JWT_SECRET` | Ключ подписи токенов. Обязателен, не короче 32 символов, заглушки отклоняются: без корректного значения приложение не стартует |
| `ADMIN_USERNAME`, `ADMIN_PASSWORD` | Первый администратор; пароль не короче 8 символов |
| `APP_PORT` | Порт UI и API на хосте (8088) |
| `APP_BIND` | Адрес публикации порта: по умолчанию `0.0.0.0`; `127.0.0.1` — только за reverse-proxy |
| `DB_HOST_PORT` | Порт PostgreSQL на хосте, публикуется только на `127.0.0.1` |
| `TRUSTED_PROXIES` | CIDR доверенных прокси через запятую; только от них принимается `X-Forwarded-For` |
## Архитектура ## Архитектура
| Слой | Технологии | | Слой | Технологии |
|---|---| |---|---|
| API | FastAPI, pydantic v2, JWT (argon2), роли `admin` (запись) / `viewer` (чтение) | | API | FastAPI, pydantic v2, JWT (срок 8 ч), пароли в argon2, роли `admin` (запись) и `viewer` (чтение) |
| БД | PostgreSQL 16, SQLAlchemy 2, Alembic (`alembic/versions`), типы `CIDR`/`INET` | | БД | PostgreSQL 16, SQLAlchemy 2, Alembic (миграции `0001`–`0009`), типы `CIDR`/`INET` |
| UI | статический SPA (ES-модуль, vanilla JS), раздаётся приложением; шрифты IBM Plex — локально в `web/fonts/` (woff2 latin+cyrillic, лицензия OFL), внешних зависимостей нет | | UI | Статический SPA (vanilla JS, ES-модуль) раздаётся приложением; шрифты IBM Plex хранятся локально, внешних зависимостей нет |
``` ```
app/ main.py config.py db.py security.py models.py schemas.py services.py api/v1/{auth,refs,prefixes,overview}.py app/ main.py config.py db.py security.py models.py schemas.py services.py request_context.py rotation.py
web/ index.html styles.css app.js app/api/v1/ auth.py refs.py prefixes.py overview.py journal.py users.py
alembic/ scripts/{gen_env,seed_demo,find_duplicate_addresses,find_unusable_addresses}.py requirements.lock tests/ docs/changes/ docs/reviews/ web/ index.html styles.css app.js fonts/
alembic/ миграции схемы
scripts/ gen_env.py seed_demo.py find_duplicate_addresses.py find_unusable_addresses.py
tests/ автотесты (pytest)
docs/changes/ планы и итоги доработок docs/reviews/ отчёты ревью
``` ```
## Модель данных ## Модель данных и правила
`organizations` → `vrfs` (по организации, «default» создаётся автоматически) → `prefixes` (дерево через `parent_id`, `organizations` → `vrfs` → `prefixes` (дерево) → `addresses`; `devices` + `device_types`; `isps` + `isp_networks`; `users`; `audit_log`.
вложенность определяется автоматически) → `addresses`; `devices` + `device_types`; `isps` + `isp_networks`; `users`; `audit_log`.
- Ёмкость префикса — размер его собственной подсети (IPv4 без сетевого/broadcast), у листа и у родителя одинаково: после частичного разбиения **VRF и префиксы**
(автовыделение, изменение 010) ёмкость родителя не падает до суммы вложенных (изменение 025). `used` считается по адресам всего поддерева того же VRF. - У каждой организации автоматически создаётся VRF `default`. Имя VRF уникально в пределах организации без учёта регистра.
- «Обзор»: `capacity` — сумма ёмкостей «корневых» активных IPv4-префиксов (не вложенных ни в один другой активный IPv4-префикс того же VRF по CIDR, - Префикс принадлежит VRF своей организации; это гарантирует составной FK в БД.
а не по `parent_id`, чтобы неактивные ветки не влияли на корни); `assigned`/`reserved` — IPv4-адреса этих статусов, лежащие внутри какого-либо корня - Вложенность префиксов (`parent_id`) определяется автоматически по CIDR. Смена VRF переносит префикс вместе с вложенными, только в пределах организации.
(адреса в неактивных ветках не учитываются). «Высокая загрузка» — как раньше, по листовым IPv4-префиксам с ёмкостью > 1 (изменение 025). - Автовыделение вложенного префикса: система выбирает первый свободный выровненный блок заданного размера.
- «Свободные» адреса не хранятся, а вычисляются; в списке адресов они показываются для подсетей до /20. Страница `status=free` считается арифметически - Изменения дерева одного VRF выполняются по одному (advisory-lock); разные VRF друг друга не блокируют.
(без перебора адресов, `offset` ≤ 10 000 000), остальные фильтры и постраничная выдача — в SQL (изменение 014).
- **IP уникален в пределах VRF и хранится в самом узком префиксе** (изменение 011): `addresses.vrf_id` + составной FK `(prefix_id, vrf_id)` (обновляется каскадом при переносе VRF), **Адреса**
уникальность `(vrf_id, address)`. Адрес из диапазона вложенного префикса в родителе назначить нельзя (422), дубль в VRF → 409; новый вложенный префикс забирает адреса родителя - IP уникален в пределах VRF и хранится в самом узком содержащем его префиксе.
из своего диапазона (`moved_addresses` в журнале); перенос префикса в VRF с тем же адресом → 409. Миграция 0007 останавливается при дублях — найти: `scripts/find_duplicate_addresses.py`. - Адрес сети и broadcast (IPv4, префикс ≤ /30) назначить нельзя.
- Адрес сети и broadcast (IPv4, префикс ≤ /30) назначить нельзя (422, изменение 015); уже внесённые найдёт `scripts/find_unusable_addresses.py`; загрузка не превышает 100 %. - Свободные адреса не хранятся, а вычисляются. В общем списке они показываются только для подсетей до /20.
Перенос адресов при создании вложенного префикса или смене VRF, из-за которого адрес стал бы сетевым/broadcast в новом месте, тоже отклоняется 422 с перечислением адресов и целевых
префиксов (изменение 024): создание префикса откатывается целиком, смена VRF — без частичных изменений. **Ёмкость и «Обзор»**
- Обзор считает использование только по IPv4. - Ёмкость префикса — размер его подсети (для IPv4 без адреса сети и broadcast). Занятость считается по всему поддереву.
- VRF — часть адресного плана организации: имя уникально **в пределах организации** (без учёта регистра), в разных организациях - «Обзор» учитывает только IPv4: ёмкость — сумма корневых активных префиксов, назначенные адреса — адреса внутри них.
имена могут совпадать; в одном VRF может быть много префиксов. Принадлежность VRF организации префикса гарантирует составной FK в БД.
- Смена VRF у префикса (`PATCH /prefixes/{id}` с `vrf_id`) — только среди VRF той же организации; переносится префикс вместе с вложенными, **Удаление**
дубль CIDR в целевом VRF → 409 (без частичных изменений), VRF другой организации → 422. - Объекты с зависимыми данными не удаляются (409). Отказ фиксируется в журнале с перечнем мешающих объектов.
- Автовыделение вложенного префикса (изменение 010): `POST /prefixes/{id}/subnets/next` с `length` (например 30) создаёт дочерний префикс в первом свободном выровненном блоке родителя (учитываются вложенные префиксы
и адреса родителя); `GET` с тем же путём и `?length=` — предпросмотр. Нет места → 409, недопустимый размер → 422. В UI — пункт «Добавить вложенный (авто)» в меню «⋯» префикса.
- VRF, тип устройства, организация с зависимыми объектами не удаляются (409). У организации без префиксов, устройств и операторов
служебный VRF `default` удаляется вместе с ней (исправлено в изменении 007: раньше такое удаление давало 409).
- Изменения дерева префиксов и раскладки адресов одного VRF (создание/удаление/перенос префикса, автовыделение, назначение адреса) выполняются по одному —
`pg_advisory_xact_lock` по `vrf_id` берётся до чтения дерева; разные VRF друг друга не блокируют, чтения (`GET`) не блокируются (изменение 029, находка №5).
## API (`/api/v1`) ## API (`/api/v1`)
`POST /auth/login` · `GET /auth/me` · `GET /overview` | Область | Эндпоинты |
CRUD: `/organizations`, `/vrfs`, `/isps`, `/device-types`, `/devices`, `/prefixes`, `/addresses/{id}`, `/users` |---|---|
Адреса префикса: `GET|POST /prefixes/{id}/addresses` (`status`, `q`, `limit`, `offset`), `POST …/addresses/next` — автоназначение из пула | Вход | `POST /auth/login`, `GET /auth/me` |
(первый свободный адрес вне вложенных префиксов, без адреса сети/broadcast; префикс блокируется на время выдачи — изменение 020). | Справочники | `/organizations`, `/vrfs`, `/isps`, `/device-types`, `/devices` |
Пагинация во всех списках: `limit` ≥ 1 (и не больше предела эндпоинта), `offset` от 0 до 10 000 000; иначе 422 (изменение 013). | Префиксы | `/prefixes`, `GET\|POST /prefixes/{id}/subnets/next` (предпросмотр и автовыделение вложенного) |
`PATCH`: `null` в текстовом поле очищает его (пустая строка), `null` в `status` адреса → 422, `device_id: null` отвязывает устройство (изменение 018). | Адреса | `GET\|POST /prefixes/{id}/addresses`, `POST /prefixes/{id}/addresses/next` (автоназначение из пула), `PATCH\|DELETE /addresses/{id}` |
`PATCH /devices/{id}` проверяет `name` (hostname/FQDN) и `mac` (формат, нормализация к `AA:BB:CC:DD:EE:FF`) так же, как создание — раньше PATCH пропускал некорректные значения (изменение 024). | Пользователи | `/users`, `POST /users/me/password` |
Поиск (`q`) экранирует `%` и `_`. Ответы содержат заголовки `Content-Security-Policy` (кроме `/docs`), `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy` (изменение 023). | Журнал | `GET /audit`, `/audit/summary`, `/audit/facets`, `/audit/{uid}`, `GET\|PUT /journal/settings`, `POST /journal/clear` |
Ошибки: `{code, message, fields}` (для блокировок/лимитов — дополнительные поля `attempts_left`, `retry_after_seconds`). Каждое изменение пишется в `audit_log`. | Сводка | `GET /overview` |
### Пользователи и роли **Соглашения**
- `GET|POST /users`, `PATCH|DELETE /users/{id}` — управление учётными записями, только `admin`: логин (3–100 символов, латиница, - Ошибки возвращаются в формате `{code, message, fields}`; для лимитов добавляются `retry_after_seconds` и `attempts_left`.
цифры, `. _ -`), роль, доступ и пароль (при сбросе администратором). Логин после создания не меняется — он же `sub` в токене. - Пагинация: `limit` ≥ 1, `offset` от 0 до 10 000 000.
- `POST /users/me/password {current_password, new_password}` — смена своего пароля, доступна любой роли; неверный текущий пароль → 403. Ответ содержит новый `access_token`: - `null` в текстовом поле `PATCH` очищает его.
прежние токены пользователя после смены пароля (самим или администратором) недействительны (изменение 023, claim `pv`). - Каждое изменение данных пишется в журнал аудита.
- `PATCH /users/{id}` с `password` для **своей же** учётной записи → 422: свой пароль меняется только через `/users/me/password` (там обязательно подтверждение текущего пароля; изменение 024).
- Занятый логин (в том числе в другом регистре) → 409; служебные логины `system` и `anonymous`, короткий логин или пароль → 422.
- Свою учётную запись нельзя понизить, отключить или удалить, как и последнего активного администратора → 409.
- Отключение действует немедленно (токен проверяется по `users.is_active` на каждом запросе). Роль `viewer` видит реестр и журнал, но любые изменения получает с 403.
- Роль по умолчанию при создании — `viewer` (изменение 016). Логин уникален без учёта регистра и в БД (изменение 021); снятие прав администратора и удаление пользователей идут под advisory-lock.
- Изменения пишутся в журнал: `user.created`, `user.updated`, `user.password_reset`, `user.deleted` (значения паролей не сохраняются).
### Журнал ## Безопасность
- Отказ в удалении по бизнес-правилу (409: организация, VRF, тип устройства, префикс с адресами, пользователь) фиксируется событием `<сущность>.delete_blocked` (изменение 008); **Роли и учётные записи**
в «Данных» записи — `reason` и `blocked_by` со списками мешающих объектов (`total` и до 20 названий: префиксы, устройства, операторы, адреса). - `viewer` только читает. Раздел «Пользователи» доступен только `admin`. Роль по умолчанию — `viewer`.
- `GET /audit` — поиск и фильтры: `q` (сообщение, метка объекта, начало ID записи), `event_type` (`prefix.created`), `entity_type`, `actor` (`ui:admin`, `system`, `anonymous`), `date_from`/`date_to` (UTC), `limit`/`offset`; `GET /audit/summary`, `/audit/facets`, `/audit/{uid}`. - Логин уникален без учёта регистра.
- `GET|PUT /journal/settings` — ротация: `retention_days` (по умолчанию 90) и `max_entries` (100 000), `0` — без ограничения. Ротация идёт раз в час и сразу при сохранении настроек - Свою учётную запись нельзя понизить, отключить или удалить; то же относится к последнему активному администратору.
(advisory-lock защищает от параллельного запуска); каждая ротация с удалениями фиксируется записью `journal.rotated`. Запись — только admin.
- `POST /journal/clear {password}` — очистка с подтверждением пароля текущего пользователя (admin); 5 неверных попыток за 10 минут → блокировка на 10 минут (429). В журнале остаётся запись `journal.cleared`. **Пароли и токены**
- **IP и метаданные запроса:** каждая запись, созданная в рамках HTTP-запроса, хранит `client_ip` и `meta` (`user_agent`, `method`, `path`, `request_id`; ответ содержит `X-Request-ID`); - Смена пароля отзывает ранее выданные токены. Свой пароль меняется только с подтверждением текущего.
системные события (ротация) — без IP. Фильтр `GET /audit?client_ip=` принимает IP или подсеть (`192.168.5.0/24`), текстовый поиск `q` ищет и по началу IP.
IP берётся из адреса сокета. `X-Forwarded-For` учитывается только от прокси из `TRUSTED_PROXIES` (CIDR через запятую в `.env`, по умолчанию пусто) — иначе IP можно подделать. **Вход**
Запросы с самой машины через `127.0.0.1` Docker показывает адресом шлюза сети (`172.x.0.1`); с LAN-адреса и удалённых хостов виден реальный источник. - Лимит неудачных попыток за 10 минут:
- В журнал пишутся также входы (`session.login`, `session.failed` — актор `anonymous`, только первая неудача в окне **по этому логину**; `session.locked` — блокировка, `diff.distinct_logins` — - 5 — на пару логин + IP;
число разных логинов с этого IP в окне, если сработал лимит по IP) и служебные события (`journal.*`, актор `system`; неверный пароль очистки — `journal.clear_failed` / `journal.clear_locked`). - 20 — на IP;
Удаление префикса с `force=true` фиксирует `addresses_deleted`. - 50 — на логин со всех IP, кроме тех, с которых пользователь успешно входил за 30 дней.
- **Вход:** три области лимита за 10 минут (блокировка — окно после последней неудачи): **логин + IP** — 5 неудач блокирует эту пару; **IP** — 20 неудач блокирует - Сверх лимита — 429 с `Retry-After`.
любые логины с этого IP (как раньше); **логин** — 50 неудач со всех IP блокирует логин, но **не для «известных» IP** — тех, с которых этот пользователь уже успешно
входил за последние 30 дней (`known_logins`, обновляется при каждом успешном входе; устаревшие записи удаляет ротация). Так анонимный клиент, знающий логин **Журнал**
(например, `admin`), не может держать пользователя заблокированным с его обычного рабочего места — только с незнакомых IP (изменение 026, находка №2 ревью). 429 содержит - Хранит IP клиента и метаданные запроса.
`Retry-After`/`retry_after_seconds`; для несуществующего логина время ответа выравнивается, пароль ≤ 128 символов. - Ротация по сроку (90 дней) и по количеству записей (100 000); значения настраиваются.
Попытки одного логина сериализованы (`pg_advisory_xact_lock`, изменение 024), затем — попытки одного IP по всем логинам (изменение 027, находка №7: без этого перебор разных - Очистка журнала требует пароль.
логинов с одного IP проходит проверку лимита по IP параллельно и превышает его на степень параллелизма); порядок всегда «логин, затем IP», взаимная блокировка исключена.
Цена — попытки с одного IP (в том числе за NAT) обрабатываются по одной, время ответа при массовом переборе растёт на время проверки пароля. **Ответы**
При ротации по количеству первыми удаляются `session.failed` и `session.locked`. За reverse-proxy без `TRUSTED_PROXIES` все клиенты делят один IP — лимит по IP заденет всех. - Заголовки CSP, `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`.
## Публикация и эксплуатация ## Публикация и эксплуатация
- Контейнер приложения работает от непривилегированного пользователя (uid 10001), у сервиса есть healthcheck (`/healthz`), сервисы перезапускаются (`restart: unless-stopped`). - Контейнер приложения работает от непривилегированного пользователя, у него есть healthcheck (`/healthz`) и перезапуск `unless-stopped`.
- Миграции при старте выполняются под advisory-lock — параллельные реплики не гоняют их одновременно. Зависимости зафиксированы в `requirements.lock` - Миграции выполняются при старте под advisory-lock. Зависимости зафиксированы в `requirements.lock`
(обновление: `venv/bin/pip-compile --strip-extras -o requirements.lock requirements.txt`). (обновление: `venv/bin/pip-compile --strip-extras -o requirements.lock requirements.txt`).
- TLS: приложение отдаёт HTTP; для эксплуатации поставьте reverse-proxy (пример для Caddy) и задайте `APP_BIND=127.0.0.1`, `TRUSTED_PROXIES=<адрес прокси/сеть Docker>`: - Приложение отдаёт HTTP. Для TLS поставьте reverse-proxy и задайте `APP_BIND=127.0.0.1` и `TRUSTED_PROXIES`. Пример для Caddy:
``` ```
ipam.example.com { ipam.example.com {
reverse_proxy 127.0.0.1:8088 reverse_proxy 127.0.0.1:8088
} }
``` ```
- Перед обновлением рабочей БД проверьте данные скриптами только для чтения: `scripts/find_duplicate_addresses.py` и `scripts/find_unusable_addresses.py`.
- Имя compose-проекта задаётся флагом `-p`. Текущий стенд поднят как `ipam_control_006` (`docker compose -p ipam_control_006 …`); без флага команды работают с проектом `ipam_control`.
## Поведение таблиц UI ## Интерфейс
Администратор может выбирать строки чекбоксами (в шапке — «выбрать все») на экранах «Организации», «Операторы», «Устройства», «Префиксы» (листовые), «Адреса» (кроме «Свободен») и «Пользователи» (кроме себя); - Экраны: «Обзор», «Префиксы» (дерево по VRF), «Адреса» подсети, «Организации», «Операторы», «Устройства», «Журнал», «Пользователи» (только admin).
в «Журнале» выбора нет. Панель над таблицей: «Удалить» везде, «Сменить тип» (устройства), «Статус» (префиксы, адреса), «Разрешить/Отключить доступ» (пользователи). Операции идут по одному запросу - Строка реестра кликабельна целиком. Действия над строкой — в меню «⋯».
на объект (изменение 009): итог «выполнено N из M», отказы (зависимые объекты, свой аккаунт, последний администратор) показаны списком с причиной, попадают в журнал как `*.delete_blocked`, - Групповые операции через чекбоксы (кроме «Журнала»): удаление, смена типа устройств, статус префиксов и адресов, доступ пользователей.
и остаются выбранными. Префиксы удаляются без `force`: префикс с адресами удаляется из одиночного меню строки. - Экран «Префиксы» загружает до 20 000 префиксов организации и сообщает, если загружены не все.
Строка реестра кликабельна целиком (как в журнале): «Префиксы» — лист открывает адреса подсети, родитель сворачивает/разворачивает ветку; «Организации» — префиксы организации;
«Операторы», «Устройства» и «Адреса» — окно редактирования (свободный адрес — «Назначить адрес» с этим IP). Ссылки, шеврон, меню «⋯» работают как раньше и не запускают действие строки;
Ctrl/Shift+клик и выделение текста тоже игнорируются.
Экран «Пользователи» доступен только администратору: создание, редактирование роли и доступа, удаление на месте, а свой пароль меняется кнопкой «Сменить пароль» в шапке.
Экраны «Префиксы» и «Адреса» догружают все страницы `/prefixes` организации (не только первые 1000), но не больше `PREFIX_UI_CAP` (20 000) префиксов; заголовок,
вкладка «Все» и подвал показывают `total`, а при срабатывании предела над таблицей появляется строка «Загружено X из N…» (изменение 028, находка №3).
## Тесты ## Тесты
Идут против приложения в контейнерах, учётные данные берутся из `.env`: Тесты работают с приложением и БД, заданными в `.env`, то есть с запущенным стендом: они создают и удаляют временные данные.
```bash ```bash
docker compose up -d --build && venv/bin/python -m pytest -q docker compose -p ipam_control_006 up -d --build && venv/bin/python -m pytest -q
``` ```
## История изменений
Каждая доработка описана в `docs/changes/<номер>/`: `PLAN.md` — план, `SUMMARY.md` — итог.
| № | Изменение | Документы |
|---|---|---|
| 001 | Backend и UI-админка | [план](docs/changes/001-ipam-backend/PLAN.md) · [итог](docs/changes/001-ipam-backend/SUMMARY.md) |
| 002 | Смена VRF у префикса, целостность «VRF ⊂ организация» | [план](docs/changes/002-prefix-vrf-change/PLAN.md) · [итог](docs/changes/002-prefix-vrf-change/SUMMARY.md) |
| 003 | Журнал: поиск, ротация, очистка | [план](docs/changes/003-journal-search-rotation/PLAN.md) · [итог](docs/changes/003-journal-search-rotation/SUMMARY.md) |
| 004 | Журнал: IP клиента и метаданные запроса | [план](docs/changes/004-audit-client-ip/PLAN.md) · [итог](docs/changes/004-audit-client-ip/SUMMARY.md) |
| 005 | Кликабельные строки реестров | [план](docs/changes/005-clickable-rows/PLAN.md) · [итог](docs/changes/005-clickable-rows/SUMMARY.md) |
| 006 | Раздел «Пользователи» | [план](docs/changes/006-users-management/PLAN.md) · [итог](docs/changes/006-users-management/SUMMARY.md) |
| 007 | Исправление удаления организации | [план](docs/changes/007-org-delete-fix/PLAN.md) · [итог](docs/changes/007-org-delete-fix/SUMMARY.md) |
| 008 | Журнал отклонённых удалений | [план](docs/changes/008-blocked-delete-audit/PLAN.md) · [итог](docs/changes/008-blocked-delete-audit/SUMMARY.md) |
| 009 | Групповые операции в UI | [план](docs/changes/009-bulk-actions/PLAN.md) · [итог](docs/changes/009-bulk-actions/SUMMARY.md) |
| 010 | Автовыделение вложенного префикса | [план](docs/changes/010-next-free-prefix/PLAN.md) · [итог](docs/changes/010-next-free-prefix/SUMMARY.md) |
| 011 | Уникальность IP в VRF | [план](docs/changes/011-address-unique-in-vrf/PLAN.md) · [итог](docs/changes/011-address-unique-in-vrf/SUMMARY.md) |
| 012 | Ограничение попыток входа | [план](docs/changes/012-login-rate-limit/PLAN.md) · [итог](docs/changes/012-login-rate-limit/SUMMARY.md) |
| 013 | Границы пагинации | [план](docs/changes/013-pagination-bounds/PLAN.md) · [итог](docs/changes/013-pagination-bounds/SUMMARY.md) |
| 014 | Производительность экрана адресов | [план](docs/changes/014-addresses-listing-performance/PLAN.md) · [итог](docs/changes/014-addresses-listing-performance/SUMMARY.md) |
| 015 | Запрет адреса сети и broadcast | [план](docs/changes/015-network-broadcast-addresses/PLAN.md) · [итог](docs/changes/015-network-broadcast-addresses/SUMMARY.md) |
| 016 | Роль по умолчанию — «Просмотр» | [план](docs/changes/016-default-role-viewer/PLAN.md) · [итог](docs/changes/016-default-role-viewer/SUMMARY.md) |
| 017 | Проверка секретов при старте | [план](docs/changes/017-jwt-secret-validation/PLAN.md) · [итог](docs/changes/017-jwt-secret-validation/SUMMARY.md) |
| 018 | `null` в PATCH | [план](docs/changes/018-patch-null-handling/PLAN.md) · [итог](docs/changes/018-patch-null-handling/SUMMARY.md) |
| 019 | Устранение N+1 запросов | [план](docs/changes/019-n-plus-one-queries/PLAN.md) · [итог](docs/changes/019-n-plus-one-queries/SUMMARY.md) |
| 020 | Автоназначение адреса с учётом вложенных префиксов | [план](docs/changes/020-next-free-address/PLAN.md) · [итог](docs/changes/020-next-free-address/SUMMARY.md) |
| 021 | Блокировки: администраторы, регистр логина | [план](docs/changes/021-concurrency-locks/PLAN.md) · [итог](docs/changes/021-concurrency-locks/SUMMARY.md) |
| 022 | Эксплуатация: контейнер, миграции, TLS, зависимости | [план](docs/changes/022-ops-hardening/PLAN.md) · [итог](docs/changes/022-ops-hardening/SUMMARY.md) |
| 023 | Безопасность и журнал: мелкие улучшения | [план](docs/changes/023-minor-hardening/PLAN.md) · [итог](docs/changes/023-minor-hardening/SUMMARY.md) |
| 024 | Исправление находок ревью 011–023 | [план](docs/changes/024-review-fixes-011-023/PLAN.md) · [итог](docs/changes/024-review-fixes-011-023/SUMMARY.md) |
| 025 | Ёмкость частично разбитого префикса | [план](docs/changes/025-prefix-capacity/PLAN.md) · [итог](docs/changes/025-prefix-capacity/SUMMARY.md) |
| 026 | Политика блокировки входа | [план](docs/changes/026-login-lockout-policy/PLAN.md) · [итог](docs/changes/026-login-lockout-policy/SUMMARY.md) |
| 027 | Сериализация попыток входа по IP | [план](docs/changes/027-login-ip-serialization/PLAN.md) · [итог](docs/changes/027-login-ip-serialization/SUMMARY.md) |
| 028 | Экран «Префиксы» без усечения | [план](docs/changes/028-prefixes-ui-pagination/PLAN.md) · [итог](docs/changes/028-prefixes-ui-pagination/SUMMARY.md) |
| 029 | Целостность дерева префиксов | [план](docs/changes/029-prefix-tree-lock/PLAN.md) · [итог](docs/changes/029-prefix-tree-lock/SUMMARY.md) |
| 030 | Исправление замечаний ревью 025–029 | [план](docs/changes/030-review-fixes-025-029/PLAN.md) · [итог](docs/changes/030-review-fixes-025-029/SUMMARY.md) |
## Отчёты ревью
- [Ревью кодовой базы](docs/reviews/2026-09-26-codebase-review.md) (находки → изменения 011–023)
- [Ревью изменений 011–023](docs/reviews/2026-09-26-changes-011-023-review.md) (→ 024)
- [Повторный анализ кодовой базы](docs/reviews/2026-09-26-codebase-review-2.md) (→ 025–029)
- [Ревью и тестирование изменений 025–029](docs/reviews/2026-09-26-changes-025-029-review.md) (→ 030)
- [Ревью и тестирование изменения 030](docs/reviews/2026-09-27-changes-030-review.md)