Files
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

8.2 KiB
Raw Permalink Blame 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) — сознательное, по запросу пользователя; остальная вёрстка не меняется.

Порядок работ

  1. Скопировать план в docs/changes/004-audit-client-ip/PLAN.md.
  2. Миграция 0004 + модель (проверка upgrade → check → downgrade → upgrade).
  3. request_context.py, конфиг, middleware, audit().
  4. /audit: поле, фильтр, поиск; Cache-Control для статики.
  5. UI: колонка и строки окна записи.
  6. Тесты, проверка через 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/прокси видна адресация последнего доверенного сегмента, а не «настоящего» узла клиента.