Задачи 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>
This commit is contained in:
ayurishchevandClaude Opus 5.5 committed 2026-09-26 21:33:50 +03:00
1 parent cd09ef0805
commit 13e17fbb47
57 files changed
+1748 -166

No files matched your search

+24 -3
View File
@@ -62,6 +62,7 @@ class Prefix(Base):
__tablename__ = "prefixes"
__table_args__ = (
UniqueConstraint("vrf_id", "prefix"),
UniqueConstraint("id", "vrf_id", name="uq_prefixes_id_vrf_id"), # цель составного FK адресов
ForeignKeyConstraint(["vrf_id", "organization_id"], ["vrfs.id", "vrfs.organization_id"], name="fk_prefixes_vrf_org"),
)
id: Mapped[int] = mapped_column(primary_key=True)
@@ -116,9 +117,15 @@ class Device(Base):
class Address(Base):
__tablename__ = "addresses"
__table_args__ = (UniqueConstraint("prefix_id", "address"),)
# vrf_id дублирует VRF префикса (составной FK, обновляется каскадом при переносе): так БД гарантирует уникальность IP в VRF
__table_args__ = (
UniqueConstraint("prefix_id", "address"),
UniqueConstraint("vrf_id", "address", name="uq_addresses_vrf_address"),
ForeignKeyConstraint(["prefix_id", "vrf_id"], ["prefixes.id", "prefixes.vrf_id"], name="fk_addresses_prefix_vrf", ondelete="CASCADE", onupdate="CASCADE"),
)
id: Mapped[int] = mapped_column(primary_key=True)
prefix_id: Mapped[int] = mapped_column(ForeignKey("prefixes.id", ondelete="CASCADE"), index=True)
prefix_id: Mapped[int] = mapped_column(index=True)
vrf_id: Mapped[int] = mapped_column()
address: Mapped[str] = mapped_column(INET)
status: Mapped[AddressStatus] = mapped_column(Enum(AddressStatus, name="address_status"), default=AddressStatus.assigned)
dns_name: Mapped[str] = mapped_column(String(255), default="")
@@ -133,8 +140,12 @@ class User(Base):
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(100), unique=True)
password_hash: Mapped[str] = mapped_column(String(255))
role: Mapped[Role] = mapped_column(Enum(Role, name="user_role"), default=Role.admin)
role: Mapped[Role] = mapped_column(Enum(Role, name="user_role"), default=Role.viewer)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
password_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # токены с iat раньше — недействительны
Index("uq_users_lower_username", func.lower(User.username), unique=True) # логин уникален без учёта регистра
class AuditLog(Base):
@@ -161,6 +172,16 @@ class AppSetting(Base):
value: Mapped[dict] = mapped_column(JSONB)
class LoginAttempt(Base):
"""Неудачные попытки входа (для ограничения перебора паролей); записи старше суток удаляет ротация."""
__tablename__ = "login_attempts"
id: Mapped[int] = mapped_column(primary_key=True)
ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
client_ip: Mapped[str | None] = mapped_column(INET)
username: Mapped[str] = mapped_column(String(100)) # в нижнем регистре
__table_args__ = (Index("ix_login_attempts_ip_ts", "client_ip", "ts"), Index("ix_login_attempts_user_ts", "username", "ts"))
class ClearAttempt(Base):
"""Неудачные попытки подтверждения пароля при очистке журнала (для блокировки)."""
__tablename__ = "clear_attempts"