Задачи 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:
1 parent
cd09ef0805
commit
13e17fbb47
57 files changed
+1748
-166
No files matched your search
+91
-48
@@ -1,7 +1,7 @@
|
||||
"""Справочники: организации, VRF, операторы, типы устройств, устройства."""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import String, cast, delete, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app import schemas as s
|
||||
from app.db import get_db
|
||||
@@ -9,34 +9,41 @@ from app.models import (
|
||||
Address, AddressStatus, Device, DeviceType, Isp, IspNetwork, Organization, Prefix, User, Vrf,
|
||||
)
|
||||
from app.security import admin_user, current_user
|
||||
from app.services import apply_update, audit, blockers, commit, count, flush, get_or_404, refuse_delete
|
||||
from app.services import MAX_OFFSET, apply_update, audit, blockers, commit, contains, count, flush, get_or_404, refuse_delete
|
||||
from fastapi import HTTPException
|
||||
|
||||
router = APIRouter(dependencies=[Depends(current_user)])
|
||||
|
||||
|
||||
def _like(q: str) -> str:
|
||||
return f"%{q.strip()}%"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- organizations
|
||||
def _org_out(db: Session, o: Organization) -> s.OrgOut:
|
||||
out = s.OrgOut.model_validate(o)
|
||||
out.prefixes_count = count(db, select(Prefix.id).where(Prefix.organization_id == o.id))
|
||||
out.addresses_count = count(
|
||||
db, select(Address.id).join(Prefix).where(Prefix.organization_id == o.id, Address.status == AddressStatus.assigned)
|
||||
)
|
||||
def _org_outs(db: Session, rows: list[Organization]) -> list[s.OrgOut]:
|
||||
"""Счётчики одним GROUP BY на страницу (без запросов на каждую строку)."""
|
||||
ids = [o.id for o in rows]
|
||||
prefixes = dict(db.execute(select(Prefix.organization_id, func.count()).where(Prefix.organization_id.in_(ids)).group_by(Prefix.organization_id)).all()) if ids else {}
|
||||
addresses = dict(db.execute(
|
||||
select(Prefix.organization_id, func.count(Address.id)).join(Prefix, Prefix.id == Address.prefix_id)
|
||||
.where(Prefix.organization_id.in_(ids), Address.status == AddressStatus.assigned).group_by(Prefix.organization_id)
|
||||
).all()) if ids else {}
|
||||
out = []
|
||||
for o in rows:
|
||||
item = s.OrgOut.model_validate(o)
|
||||
item.prefixes_count, item.addresses_count = prefixes.get(o.id, 0), addresses.get(o.id, 0)
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
def _org_out(db: Session, o: Organization) -> s.OrgOut:
|
||||
return _org_outs(db, [o])[0]
|
||||
|
||||
|
||||
@router.get("/organizations", response_model=s.Page[s.OrgOut], tags=["organizations"])
|
||||
def list_orgs(q: str = "", limit: int = Query(100, le=500), offset: int = 0, db: Session = Depends(get_db)):
|
||||
def list_orgs(q: str = "", limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0, le=MAX_OFFSET), db: Session = Depends(get_db)):
|
||||
stmt = select(Organization)
|
||||
if q:
|
||||
stmt = stmt.where(or_(*(c.ilike(_like(q)) for c in (Organization.name, Organization.short_name, Organization.inn, Organization.address))))
|
||||
stmt = stmt.where(or_(*(contains(c, q) for c in (Organization.name, Organization.short_name, Organization.inn, Organization.address))))
|
||||
total = count(db, stmt)
|
||||
rows = db.scalars(stmt.order_by(Organization.id).limit(limit).offset(offset)).all()
|
||||
return s.Page(items=[_org_out(db, o) for o in rows], total=total)
|
||||
return s.Page(items=_org_outs(db, list(rows)), total=total)
|
||||
|
||||
|
||||
@router.get("/organizations/{id}", response_model=s.OrgOut, tags=["organizations"])
|
||||
@@ -82,19 +89,28 @@ def delete_org(id: int, db: Session = Depends(get_db), user: User = Depends(admi
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------- VRF
|
||||
def _vrf_out(db: Session, v: Vrf) -> s.VrfOut:
|
||||
out = s.VrfOut.model_validate(v)
|
||||
out.prefixes_count = count(db, select(Prefix.id).where(Prefix.vrf_id == v.id))
|
||||
def _vrf_outs(db: Session, rows: list[Vrf]) -> list[s.VrfOut]:
|
||||
ids = [v.id for v in rows]
|
||||
counts = dict(db.execute(select(Prefix.vrf_id, func.count()).where(Prefix.vrf_id.in_(ids)).group_by(Prefix.vrf_id)).all()) if ids else {}
|
||||
out = []
|
||||
for v in rows:
|
||||
item = s.VrfOut.model_validate(v)
|
||||
item.prefixes_count = counts.get(v.id, 0)
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
def _vrf_out(db: Session, v: Vrf) -> s.VrfOut:
|
||||
return _vrf_outs(db, [v])[0]
|
||||
|
||||
|
||||
@router.get("/vrfs", response_model=s.Page[s.VrfOut], tags=["vrf"])
|
||||
def list_vrfs(organization_id: int | None = None, db: Session = Depends(get_db)):
|
||||
stmt = select(Vrf)
|
||||
if organization_id:
|
||||
stmt = stmt.where(Vrf.organization_id == organization_id)
|
||||
rows = db.scalars(stmt.order_by(Vrf.id)).all()
|
||||
return s.Page(items=[_vrf_out(db, v) for v in rows], total=len(rows))
|
||||
return s.Page(items=_vrf_outs(db, list(rows)), total=len(rows))
|
||||
|
||||
|
||||
@router.post("/vrfs", response_model=s.VrfOut, status_code=201, tags=["vrf"])
|
||||
@@ -129,16 +145,25 @@ def delete_vrf(id: int, db: Session = Depends(get_db), user: User = Depends(admi
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- device types
|
||||
def _type_out(db: Session, t: DeviceType) -> s.DeviceTypeOut:
|
||||
out = s.DeviceTypeOut.model_validate(t)
|
||||
out.devices_count = count(db, select(Device.id).where(Device.device_type_id == t.id))
|
||||
def _type_outs(db: Session, rows: list[DeviceType]) -> list[s.DeviceTypeOut]:
|
||||
ids = [t.id for t in rows]
|
||||
counts = dict(db.execute(select(Device.device_type_id, func.count()).where(Device.device_type_id.in_(ids)).group_by(Device.device_type_id)).all()) if ids else {}
|
||||
out = []
|
||||
for t in rows:
|
||||
item = s.DeviceTypeOut.model_validate(t)
|
||||
item.devices_count = counts.get(t.id, 0)
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
def _type_out(db: Session, t: DeviceType) -> s.DeviceTypeOut:
|
||||
return _type_outs(db, [t])[0]
|
||||
|
||||
|
||||
@router.get("/device-types", response_model=s.Page[s.DeviceTypeOut], tags=["devices"])
|
||||
def list_types(db: Session = Depends(get_db)):
|
||||
rows = db.scalars(select(DeviceType).order_by(DeviceType.id)).all()
|
||||
return s.Page(items=[_type_out(db, t) for t in rows], total=len(rows))
|
||||
return s.Page(items=_type_outs(db, list(rows)), total=len(rows))
|
||||
|
||||
|
||||
@router.post("/device-types", response_model=s.DeviceTypeOut, status_code=201, tags=["devices"])
|
||||
@@ -174,23 +199,36 @@ def delete_type(id: int, db: Session = Depends(get_db), user: User = Depends(adm
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- devices
|
||||
def _device_outs(db: Session, devices: list[Device]) -> list[s.DeviceOut]:
|
||||
"""Адреса и названия типов — двумя запросами на страницу."""
|
||||
ids = [d.id for d in devices]
|
||||
by_device: dict[int, list] = {}
|
||||
if ids:
|
||||
for dev_id, addr, prefix_id, status in db.execute(
|
||||
select(Address.device_id, Address.address, Address.prefix_id, Address.status).where(Address.device_id.in_(ids)).order_by(Address.address)
|
||||
):
|
||||
by_device.setdefault(dev_id, []).append((addr, prefix_id, status))
|
||||
type_names = dict(db.execute(select(DeviceType.id, DeviceType.name)).all())
|
||||
out = []
|
||||
for d in devices:
|
||||
rows = by_device.get(d.id, [])
|
||||
out.append(s.DeviceOut(
|
||||
id=d.id, name=d.name, device_type_id=d.device_type_id, device_type_name=type_names[d.device_type_id],
|
||||
organization_id=d.organization_id, mac=d.mac, note=d.note,
|
||||
ip_addresses=[s.ip_text(r[0]) for r in rows], first_prefix_id=rows[0][1] if rows else None,
|
||||
all_deprecated=bool(rows) and all(r[2] == AddressStatus.deprecated for r in rows),
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
def _device_out(db: Session, d: Device) -> s.DeviceOut:
|
||||
rows = db.execute(
|
||||
select(Address.address, Address.prefix_id, Address.status).where(Address.device_id == d.id).order_by(Address.address)
|
||||
).all()
|
||||
t = db.get(DeviceType, d.device_type_id)
|
||||
return s.DeviceOut(
|
||||
id=d.id, name=d.name, device_type_id=d.device_type_id, device_type_name=t.name,
|
||||
organization_id=d.organization_id, mac=d.mac, note=d.note,
|
||||
ip_addresses=[s.ip_text(r[0]) for r in rows], first_prefix_id=rows[0][1] if rows else None,
|
||||
all_deprecated=bool(rows) and all(r[2] == AddressStatus.deprecated for r in rows),
|
||||
)
|
||||
return _device_outs(db, [d])[0]
|
||||
|
||||
|
||||
@router.get("/devices", response_model=s.Page[s.DeviceOut], tags=["devices"])
|
||||
def list_devices(
|
||||
organization_id: int | None = None, device_type_id: int | None = None, q: str = "",
|
||||
limit: int = Query(100, le=500), offset: int = 0, db: Session = Depends(get_db),
|
||||
limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0, le=MAX_OFFSET), db: Session = Depends(get_db),
|
||||
):
|
||||
stmt = select(Device)
|
||||
if organization_id:
|
||||
@@ -198,11 +236,11 @@ def list_devices(
|
||||
if device_type_id:
|
||||
stmt = stmt.where(Device.device_type_id == device_type_id)
|
||||
if q:
|
||||
ip_match = select(Address.device_id).where(func.host(Address.address).ilike(_like(q)))
|
||||
stmt = stmt.where(or_(Device.name.ilike(_like(q)), Device.note.ilike(_like(q)), Device.id.in_(ip_match)))
|
||||
ip_match = select(Address.device_id).where(contains(func.host(Address.address), q))
|
||||
stmt = stmt.where(or_(contains(Device.name, q), contains(Device.note, q), Device.id.in_(ip_match)))
|
||||
total = count(db, stmt)
|
||||
rows = db.scalars(stmt.order_by(Device.id).limit(limit).offset(offset)).all()
|
||||
return s.Page(items=[_device_out(db, d) for d in rows], total=total)
|
||||
return s.Page(items=_device_outs(db, list(rows)), total=total)
|
||||
|
||||
|
||||
@router.post("/devices", response_model=s.DeviceOut, status_code=201, tags=["devices"])
|
||||
@@ -243,30 +281,35 @@ def delete_device(id: int, db: Session = Depends(get_db), user: User = Depends(a
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------ ISPs
|
||||
def _isp_out(db: Session, i: Isp) -> s.IspOut:
|
||||
org = db.get(Organization, i.organization_id)
|
||||
return s.IspOut(
|
||||
id=i.id, name=i.name, organization_id=i.organization_id, organization_name=org.name,
|
||||
def _isp_outs(db: Session, rows: list[Isp]) -> list[s.IspOut]:
|
||||
ids = {i.organization_id for i in rows}
|
||||
names = dict(db.execute(select(Organization.id, Organization.name).where(Organization.id.in_(ids))).all()) if ids else {}
|
||||
return [s.IspOut(
|
||||
id=i.id, name=i.name, organization_id=i.organization_id, organization_name=names[i.organization_id],
|
||||
networks=[str(n.cidr) for n in i.networks], hotline=i.hotline,
|
||||
contract_number=i.contract_number, note=i.note,
|
||||
)
|
||||
) for i in rows]
|
||||
|
||||
|
||||
def _isp_out(db: Session, i: Isp) -> s.IspOut:
|
||||
return _isp_outs(db, [i])[0]
|
||||
|
||||
|
||||
@router.get("/isps", response_model=s.Page[s.IspOut], tags=["isps"])
|
||||
def list_isps(
|
||||
organization_id: int | None = None, q: str = "", limit: int = Query(100, le=500), offset: int = 0,
|
||||
organization_id: int | None = None, q: str = "", limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0, le=MAX_OFFSET),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
stmt = select(Isp)
|
||||
if organization_id:
|
||||
stmt = stmt.where(Isp.organization_id == organization_id)
|
||||
if q:
|
||||
nets = select(IspNetwork.isp_id).where(cast(IspNetwork.cidr, String).ilike(_like(q)))
|
||||
orgs = select(Organization.id).where(Organization.name.ilike(_like(q)))
|
||||
stmt = stmt.where(or_(Isp.name.ilike(_like(q)), Isp.id.in_(nets), Isp.organization_id.in_(orgs)))
|
||||
nets = select(IspNetwork.isp_id).where(contains(cast(IspNetwork.cidr, String), q))
|
||||
orgs = select(Organization.id).where(contains(Organization.name, q))
|
||||
stmt = stmt.where(or_(contains(Isp.name, q), Isp.id.in_(nets), Isp.organization_id.in_(orgs)))
|
||||
total = count(db, stmt)
|
||||
rows = db.scalars(stmt.order_by(Isp.id).limit(limit).offset(offset)).all()
|
||||
return s.Page(items=[_isp_out(db, i) for i in rows], total=total)
|
||||
rows = db.scalars(stmt.options(selectinload(Isp.networks)).order_by(Isp.id).limit(limit).offset(offset)).all()
|
||||
return s.Page(items=_isp_outs(db, list(rows)), total=total)
|
||||
|
||||
|
||||
@router.post("/isps", response_model=s.IspOut, status_code=201, tags=["isps"])
|
||||
|
||||
Reference in new issue
Block a user