Files
ipam_control/docs/changes/004-audit-client-ip/PLAN.md
T

66 lines
8.2 KiB
Markdown
Raw Normal View History

# Журнал аудита: 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/прокси видна адресация последнего доверенного сегмента, а не «настоящего» узла клиента.