Files
ipam_control/docs/changes/004-audit-client-ip/PLAN.md
T
ayurishchevandClaude Sonnet 5 a846d30872 IPAM Manager: API, UI-админка, журнал аудита
Backend (FastAPI, SQLAlchemy 2, Alembic, PostgreSQL 16):
- организации, VRF, префиксы (дерево, использование, автоназначение),
  адреса, операторы связи, устройства и типы устройств; JWT, роли admin/viewer;
- VRF принадлежит организации (составной FK), смена VRF у префикса
  переносит поддерево, имя VRF уникально в организации;
- журнал аудита: поиск и фильтры, ротация (срок/количество), очистка
  по паролю с блокировкой, IP клиента и метаданные запроса
  (X-Forwarded-For только от TRUSTED_PROXIES).

UI (web/, без сборки): экраны и диалоги по макетам «IPAM Manager»,
кликабельные строки реестров, локальные шрифты IBM Plex,
собственные выпадающие списки.

Окружение: docker-compose (postgres + app), миграции Alembic 0001-0004,
scripts/gen_env.py, scripts/seed_demo.py, 11 автотестов (pytest).
Документация: README.md и docs/changes/001-005 (планы и итоги).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 12:27:47 +03:00

67 lines
8.2 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.
# Журнал аудита: IP-адрес актора и метаданные запроса (изменение 004)
## Context
В журнале видно, *кто* и *что* сделал (`actor`, `message`), но не *откуда*. Нужно фиксировать IP-адрес, с которого пользователь (в т.ч. admin)
выполнил изменение, и сопутствующие метаданные, и показывать их в UI. Сейчас `audit()` (`app/services.py`) не знает о запросе:
вызывается из ~30 мест, IP нигде не сохраняется.
Что выяснено (read-only проверка):
- Приложение публикуется через Docker (`0.0.0.0:8088 → 172.x:8000`). Запрос с LAN-адреса машины приходит с реальным источником (`192.168.5.9`),
с удалённых хостов — тоже (DNAT сохраняет источник); запрос через `127.0.0.1` приходит как адрес шлюза docker-сети (`172.29.0.1`) —
это особенность `docker-proxy` для loopback, не ошибка приложения. Отдельная прокси-настройка для получения реального IP не нужна.
- Если позже перед приложением появится reverse-proxy с TLS (как предлагалось), реальный IP будет в `X-Forwarded-For`, но доверять заголовку можно
только от известных прокси — иначе IP подделывается. Поэтому закладываем настройку доверенных прокси (по умолчанию пусто).
Заодно (жалоба «не вижу обновлений»): сервер отдаёт актуальные `app.js`/`styles.css` (проверено по LAN-адресу), но без `Cache-Control` —
браузер может держать старую версию по эвристике. Включаем ревалидацию (`Cache-Control: no-cache` + уже есть ETag).
## Решение
**Каждая запись журнала, созданная в рамках HTTP-запроса, получает `client_ip` и `meta`**; записи, созданные системой (ротация, миграции) — без IP.
Это покрывает и действия admin, и неудачные входы (`anonymous`, самый ценный случай для расследования), и любые будущие роли.
## Модель данных (миграция 0004)
- `audit_log`: `+client_ip INET NULL` (индекс), `+meta JSONB NULL` — расширяемые метаданные запроса:
`{"user_agent": "…", "method": "POST", "path": "/api/v1/prefixes", "request_id": "…"}` (User-Agent ≤ 255 символов; только путь, без query-строки).
- Существующие записи остаются с `NULL` (в UI «—»). Даунгрейд удаляет колонки.
## Backend
- `app/request_context.py` (новый): `ContextVar` с метаданными текущего запроса и ASGI-middleware `RequestContextMiddleware`:
определяет IP клиента, User-Agent, метод, путь, генерирует `request_id` (возвращается в заголовке `X-Request-ID`).
`resolve_client_ip(peer, x_forwarded_for, trusted)` — чистая функция: `X-Forwarded-For` учитывается **только если сокет-пир входит в `TRUSTED_PROXIES`**
(берётся первый недоверенный адрес справа); иначе — адрес пира. IPv4-mapped IPv6 (`::ffff:a.b.c.d`) нормализуется в IPv4.
- `app/config.py`: `trusted_proxies: str = ""` (CIDR через запятую); `.env.example`, `docker-compose.yml` (пробросить `TRUSTED_PROXIES`).
- `app/services.py::audit()`: читает контекст запроса и заполняет `client_ip`/`meta`; для `user=SYSTEM` (ротация, в т.ч. запущенная из запроса на сохранение настроек) IP не пишется.
Сигнатура `audit()` не меняется — 30 мест вызова не трогаем.
- `app/main.py`: подключить middleware; для статики — `Cache-Control: no-cache` (подкласс `StaticFiles`).
- `GET /audit`: новый фильтр `client_ip` (точный IP или подсеть CIDR — `inet <<=`); текстовый поиск `q` дополнительно ищет по началу IP.
`AuditOut` (+`from_row`): поля `client_ip: str | None`, `meta: dict | None`. Остальные поля/эндпоинты без изменений (Обзор не затронут).
## UI (`web/app.js`, `web/styles.css`)
- Таблица журнала: новая колонка «IP-адрес» (моно 13 px, «—» если нет) — сетка `150px 168px 150px 1fr 110px 130px 96px`; поиск: «Сообщение, ID или IP».
- Окно записи: строки «IP-адрес» (с кнопкой «Копировать») , «User-Agent», «Запрос» (`POST /api/v1/prefixes`); блок «Данные» остаётся для `diff`.
- Отступление от макета Journal (он без IP) — сознательное, по запросу пользователя; остальная вёрстка не меняется.
## Порядок работ
0. Скопировать план в `docs/changes/004-audit-client-ip/PLAN.md`.
1. Миграция 0004 + модель (проверка upgrade → check → downgrade → upgrade).
2. `request_context.py`, конфиг, middleware, `audit()`.
3. `/audit`: поле, фильтр, поиск; `Cache-Control` для статики.
4. UI: колонка и строки окна записи.
5. Тесты, проверка через LAN-адрес и в браузере, README, SUMMARY.
## Тесты (+2, всего 11; против контейнеров)
1. Запись IP: действие через API с заголовком `User-Agent: ipam-tests/1.0` → в `/audit` у записи есть валидный `client_ip` и `meta.user_agent`/`method`/`path`;
фильтр `client_ip` находит запись, чужой IP/подсеть — нет; поддельный `X-Forwarded-For: 203.0.113.9` от недоверенного пира **не** попадает в журнал;
у записи ротации (`system`) `client_ip = null`.
2. Юнит-тест `resolve_client_ip` (без контейнеров): недоверенный пир игнорирует XFF; доверенный — берёт первый недоверенный справа; IPv4-mapped нормализуется.
## Проверка end-to-end
`docker compose up -d --build` → `pytest` → запрос к API через `http://192.168.5.9:8088` (реальный источник) и через `127.0.0.1` (адрес шлюза docker) →
в UI «Журнал» видна колонка IP, окно записи показывает IP/User-Agent/запрос, поиск по IP находит запись → `curl -I` статики показывает `Cache-Control: no-cache`.
## Допущения (скажите, если не так)
- IP пишется для всех действий из HTTP-запросов (не только admin) — иначе журнал неоднородный; для system — пусто.
- Запросы с самой машины через `127.0.0.1` показывают адрес шлюза docker-сети; это поведение Docker и документируется в README.
- IP — персональные данные: срок хранения регулируется существующей ротацией журнала; отдельная обезличка не делается.
- За NAT/прокси видна адресация последнего доверенного сегмента, а не «настоящего» узла клиента.