034 Публикация стенда как https://rxipam.rxmsk.ru через общий Caddy хоста (Caddyfile вне репозитория): UI и /api/v1 доступны из интернета, /docs, /redoc, /openapi.json — только из частных сетей (RFC 1918/4193, loopback). Caddy проксирует на опубликованный порт приложения — общая Docker-сеть отклонена из-за коллизии имён app/db с Nextcloud. TRUSTED_PROXIES=172.16.0.0/12: реальный IP клиента из X-Forwarded-For. Интеграционный тест журнала больше не проверяет подделку XFF с хоста (хост за Caddy доверенный), логику покрывает unit-тест. 035 Дата и IP последнего входа в разделе «Пользователи»: users.last_login_at / last_login_ip (миграция 0014 с заполнением из журнала), запись при успешном входе, UserOut, колонка в UI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5.5 KiB
Последний вход пользователя: дата и IP на странице «Пользователи» (изменение 035)
Context
Суперадминистратору нужно видеть в разделе «Пользователи», когда каждый пользователь последний раз входил в систему и с какого IP.
Сейчас это можно найти только поиском по журналу. known_logins для этого не подходит: там IP за 30 дней, ротация удаляет старые записи,
и он хранит набор IP, а не последний вход.
Решение: два поля в users — время и IP последнего успешного входа. Поля обновляются в POST /auth/login,
отдаются в UserOut и выводятся колонкой в таблице пользователей.
IP берётся тот же, что уже вычисляет request_context для журнала: реальный адрес клиента за Caddy (TRUSTED_PROXIES, изменение 034).
Изменения
БД и модель
app/models.py::User:last_login_at: Mapped[datetime | None](DateTime(timezone=True));last_login_ip: Mapped[str | None](INET);- комментарий «изменение 035».
- Миграция
alembic/versions/0014_users_last_login.py(down_revision0013):- две nullable-колонки;
- backfill из журнала: для каждого пользователя берётся последнее событие
session.login(DISTINCT ON (username) … ORDER BY username, ts DESC) поaudit_log.username = users.username, из него —tsиclient_ip; - если журнал ротирован или очищен, значения остаются
NULL(«—» в UI). downgrade— удалить колонки.
API
app/api/v1/auth.py::login, ветка успешного входа (рядом с_remember_login):user.last_login_at = func.now(),user.last_login_ip = ip, в той же транзакции, что и записьsession.login. Неудачные попытки поля не трогают.app/schemas.py::UserOut:last_login_at: datetime | None = None,last_login_ip: str | None = None. INET приводить к строке тем же способом, что уже используется дляclient_ipвAuditOutи адресов (ip_text), чтобы в JSON был"192.168.5.9", а не объект.UserOutсобирается черезmodel_validateиз ORM, поэтому нужен@field_validator("last_login_ip", mode="before")с вызовомip_text().UserOutотдают и/users(толькоsuperadmin), и/auth/me(свои данные) — утечки чужих IP нет.
UI (web/app.js::screens.users)
- Новая колонка «Последний вход» после «Статус»:
- дата —
fmtDate(u.last_login_at)(как в журнале, UTC); - под ней или через разделитель — IP моноширинным шрифтом,
muted; - «—», если входа не было.
- дата —
- Расширить
cols(selCols(...)) и заголовок.thна одну колонку. Ширину подобрать так, чтобы таблица не переполнялась, по образцу соседних колонок (≈170–190px).
Тесты (минимально)
tests/test_users.py::test_users_management: после входа созданного пользователя (_client(name, ...))GET /users?q=<name>→ у записи заполненыlast_login_atиlast_login_ip. Новых тестов не добавлять.
Документация (правила проекта)
docs/changes/035-user-last-login/PLAN.md— этот план; по завершении —SUMMARY.md.README.md:- раздел «Интерфейс»: в «Пользователях» видны дата и IP последнего входа;
- миграции
0001–0014; - строка 035 в истории изменений.
Исполнение
По принятой схеме: код и тест пишет агент на Sonnet (без запуска тестов и стенда), затем ревью, пересборка стенда, проверки и SUMMARY — моя часть.
Проверка
docker compose -p ipam_control_006 up -d --build app→alembic current=0014,alembic check— чисто; откат до 0013 и повторный upgrade — без ошибок.- Backfill: у суперадминистратора из
.env, который входил,last_login_at/last_login_ipсовпадают с последнимsession.loginв журнале. - Вход через Caddy с LAN-адреса хоста (
--resolve rxipam.rxmsk.ru:443:192.168.5.9) →last_login_ip = 192.168.5.9; неудачная попытка не меняет поля. GET /users— поля в JSON строками;pytest -q— все зелёные.- UI: колонка «Последний вход» с датой и IP, «—» у пользователей без входа. В браузере проверяет пользователь, если нужно.