Files
ipam_control/README.md
T
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

121 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# IPAM Manager
Реестр IP-адресов и адресных префиксов в разрезе организаций. API-first: backend на Python (FastAPI) с PostgreSQL,
UI-админка — отдельный лёгкий SPA (`web/`, без сборки), который только визуализирует ответы API.
Макеты: страница «IPAM Manager» дизайн-канваса ros_control.
## Быстрый старт
```bash
python3 scripts/gen_env.py # .env со случайными паролями/секретами (в .gitignore)
docker compose up -d --build # postgres + app; миграции применяются автоматически
python3 -m venv venv && venv/bin/pip install -r requirements-dev.txt
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)
- Логин/пароль администратора — `ADMIN_USERNAME` / `ADMIN_PASSWORD` из `.env` (создаётся при первом старте; пароль не короче 8 символов).
- `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, см. «Публикация»).
## Архитектура
| Слой | Технологии |
|---|---|
| API | FastAPI, pydantic v2, JWT (argon2), роли `admin` (запись) / `viewer` (чтение) |
| БД | PostgreSQL 16, SQLAlchemy 2, Alembic (`alembic/versions`), типы `CIDR`/`INET` |
| UI | статический SPA (ES-модуль, vanilla JS), раздаётся приложением; шрифты IBM Plex — локально в `web/fonts/` (woff2 latin+cyrillic, лицензия OFL), внешних зависимостей нет |
```
app/ main.py config.py db.py security.py models.py schemas.py services.py api/v1/{auth,refs,prefixes,overview}.py
web/ index.html styles.css app.js
alembic/ scripts/{gen_env,seed_demo,find_duplicate_addresses,find_unusable_addresses}.py requirements.lock tests/ docs/changes/ docs/reviews/
```
## Модель данных
`organizations` → `vrfs` (по организации, «default» создаётся автоматически) → `prefixes` (дерево через `parent_id`,
вложенность определяется автоматически) → `addresses`; `devices` + `device_types`; `isps` + `isp_networks`; `users`; `audit_log`.
- Ёмкость листового префикса — размер подсети (IPv4 без сетевого/broadcast); родителя — сумма вложенных листьев.
- «Свободные» адреса не хранятся, а вычисляются; в списке адресов они показываются для подсетей до /20. Страница `status=free` считается арифметически
(без перебора адресов, `offset` ≤ 10 000 000), остальные фильтры и постраничная выдача — в SQL (изменение 014).
- **IP уникален в пределах VRF и хранится в самом узком префиксе** (изменение 011): `addresses.vrf_id` + составной FK `(prefix_id, vrf_id)` (обновляется каскадом при переносе VRF),
уникальность `(vrf_id, address)`. Адрес из диапазона вложенного префикса в родителе назначить нельзя (422), дубль в VRF → 409; новый вложенный префикс забирает адреса родителя
из своего диапазона (`moved_addresses` в журнале); перенос префикса в VRF с тем же адресом → 409. Миграция 0007 останавливается при дублях — найти: `scripts/find_duplicate_addresses.py`.
- Адрес сети и broadcast (IPv4, префикс ≤ /30) назначить нельзя (422, изменение 015); уже внесённые найдёт `scripts/find_unusable_addresses.py`; загрузка не превышает 100 %.
Перенос адресов при создании вложенного префикса или смене VRF, из-за которого адрес стал бы сетевым/broadcast в новом месте, тоже отклоняется 422 с перечислением адресов и целевых
префиксов (изменение 024): создание префикса откатывается целиком, смена VRF — без частичных изменений.
- Обзор считает использование только по IPv4.
- VRF — часть адресного плана организации: имя уникально **в пределах организации** (без учёта регистра), в разных организациях
имена могут совпадать; в одном VRF может быть много префиксов. Принадлежность VRF организации префикса гарантирует составной FK в БД.
- Смена VRF у префикса (`PATCH /prefixes/{id}` с `vrf_id`) — только среди VRF той же организации; переносится префикс вместе с вложенными,
дубль CIDR в целевом VRF → 409 (без частичных изменений), VRF другой организации → 422.
- Автовыделение вложенного префикса (изменение 010): `POST /prefixes/{id}/subnets/next` с `length` (например 30) создаёт дочерний префикс в первом свободном выровненном блоке родителя (учитываются вложенные префиксы
и адреса родителя); `GET` с тем же путём и `?length=` — предпросмотр. Нет места → 409, недопустимый размер → 422. В UI — пункт «Добавить вложенный (авто)» в меню «⋯» префикса.
- VRF, тип устройства, организация с зависимыми объектами не удаляются (409). У организации без префиксов, устройств и операторов
служебный VRF `default` удаляется вместе с ней (исправлено в изменении 007: раньше такое удаление давало 409).
## 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` — автоназначение из пула
(первый свободный адрес вне вложенных префиксов, без адреса сети/broadcast; префикс блокируется на время выдачи — изменение 020).
Пагинация во всех списках: `limit` ≥ 1 (и не больше предела эндпоинта), `offset` от 0 до 10 000 000; иначе 422 (изменение 013).
`PATCH`: `null` в текстовом поле очищает его (пустая строка), `null` в `status` адреса → 422, `device_id: null` отвязывает устройство (изменение 018).
`PATCH /devices/{id}` проверяет `name` (hostname/FQDN) и `mac` (формат, нормализация к `AA:BB:CC:DD:EE:FF`) так же, как создание — раньше PATCH пропускал некорректные значения (изменение 024).
Поиск (`q`) экранирует `%` и `_`. Ответы содержат заголовки `Content-Security-Policy` (кроме `/docs`), `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy` (изменение 023).
Ошибки: `{code, message, fields}` (для блокировок/лимитов — дополнительные поля `attempts_left`, `retry_after_seconds`). Каждое изменение пишется в `audit_log`.
### Пользователи и роли
- `GET|POST /users`, `PATCH|DELETE /users/{id}` — управление учётными записями, только `admin`: логин (3–100 символов, латиница,
цифры, `. _ -`), роль, доступ и пароль (при сбросе администратором). Логин после создания не меняется — он же `sub` в токене.
- `POST /users/me/password {current_password, new_password}` — смена своего пароля, доступна любой роли; неверный текущий пароль → 403. Ответ содержит новый `access_token`:
прежние токены пользователя после смены пароля (самим или администратором) недействительны (изменение 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 названий: префиксы, устройства, операторы, адреса).
- `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-адреса и удалённых хостов виден реальный источник.
- В журнал пишутся также входы (`session.login`, `session.failed` — актор `anonymous`, только первая неудача в окне **по этому логину**; `session.locked` — блокировка, `diff.distinct_logins` —
число разных логинов с этого IP в окне, если сработал лимит по IP) и служебные события (`journal.*`, актор `system`; неверный пароль очистки — `journal.clear_failed` / `journal.clear_locked`).
Удаление префикса с `force=true` фиксирует `addresses_deleted`.
- **Вход:** 5 неудач на логин и 20 на IP за 10 минут → блокировка на 10 минут (429, `Retry-After`, `retry_after_seconds`; изменение 012). Для несуществующего логина время ответа выравнивается.
Пароль ≤ 128 символов. Попытки одного логина сериализованы (`pg_advisory_xact_lock`) — параллельные запросы не обходят лимит (изменение 024). При ротации по количеству первыми удаляются
`session.failed` и `session.locked`. За reverse-proxy без `TRUSTED_PROXIES` все клиенты делят один IP — лимит по IP заденет всех.
## Публикация и эксплуатация
- Контейнер приложения работает от непривилегированного пользователя (uid 10001), у сервиса есть healthcheck (`/healthz`), сервисы перезапускаются (`restart: unless-stopped`).
- Миграции при старте выполняются под advisory-lock — параллельные реплики не гоняют их одновременно. Зависимости зафиксированы в `requirements.lock`
(обновление: `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>`:
```
ipam.example.com {
reverse_proxy 127.0.0.1:8088
}
```
## Поведение таблиц UI
Администратор может выбирать строки чекбоксами (в шапке — «выбрать все») на экранах «Организации», «Операторы», «Устройства», «Префиксы» (листовые), «Адреса» (кроме «Свободен») и «Пользователи» (кроме себя);
в «Журнале» выбора нет. Панель над таблицей: «Удалить» везде, «Сменить тип» (устройства), «Статус» (префиксы, адреса), «Разрешить/Отключить доступ» (пользователи). Операции идут по одному запросу
на объект (изменение 009): итог «выполнено N из M», отказы (зависимые объекты, свой аккаунт, последний администратор) показаны списком с причиной, попадают в журнал как `*.delete_blocked`,
и остаются выбранными. Префиксы удаляются без `force`: префикс с адресами удаляется из одиночного меню строки.
Строка реестра кликабельна целиком (как в журнале): «Префиксы» — лист открывает адреса подсети, родитель сворачивает/разворачивает ветку; «Организации» — префиксы организации;
«Операторы», «Устройства» и «Адреса» — окно редактирования (свободный адрес — «Назначить адрес» с этим IP). Ссылки, шеврон, меню «⋯» работают как раньше и не запускают действие строки;
Ctrl/Shift+клик и выделение текста тоже игнорируются.
Экран «Пользователи» доступен только администратору: создание, редактирование роли и доступа, удаление на месте, а свой пароль меняется кнопкой «Сменить пароль» в шапке.
## Тесты
Идут против приложения в контейнерах, учётные данные берутся из `.env`:
```bash
docker compose up -d --build && venv/bin/python -m pytest -q
```