Задачи 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
+72
-4
@@ -1,23 +1,91 @@
|
||||
"""Вход в UI. Перебор паролей ограничен: {MAX_PER_LOGIN} неудач на логин и {MAX_PER_IP} на IP за {WINDOW_MIN} минут → 429 (изменение 012)."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import get_db
|
||||
from app.models import User
|
||||
from app.models import LoginAttempt, User
|
||||
from app.request_context import request_meta
|
||||
from app.schemas import LoginIn, TokenOut, UserOut
|
||||
from app.security import create_token, current_user, verify_password
|
||||
from app.security import create_token, current_user, hash_password, verify_password
|
||||
from app.services import ANONYMOUS, audit
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
WINDOW = timedelta(minutes=10)
|
||||
MAX_PER_LOGIN = 5
|
||||
MAX_PER_IP = 20
|
||||
__doc__ = __doc__.format(MAX_PER_LOGIN=MAX_PER_LOGIN, MAX_PER_IP=MAX_PER_IP, WINDOW_MIN=int(WINDOW.total_seconds() // 60))
|
||||
_DUMMY_HASH = hash_password("dummy-password-for-timing") # выравнивает время ответа для несуществующего логина
|
||||
|
||||
|
||||
def _condition(login: str, ip: str | None, scope: str):
|
||||
return (LoginAttempt.username == login) if scope == "login" else (LoginAttempt.client_ip == ip)
|
||||
|
||||
|
||||
def _retry_after(db: Session, login: str, ip: str | None) -> tuple[int, str | None, dict[str, int]]:
|
||||
"""(секунд до конца блокировки, причина 'login'|'ip', {"login": n, "ip": m} — число неудач в каждой области, до лимита).
|
||||
Блокировка длится окно после последней неудачи. Счётчики по областям отдельно (изменение 024, находка №4):
|
||||
решение о записи в журнал принимается по конкретному логину, а не по максимуму среди логина и IP."""
|
||||
now = datetime.now(timezone.utc)
|
||||
worst, why, counts = 0, None, {}
|
||||
for scope, limit in (("login", MAX_PER_LOGIN), ("ip", MAX_PER_IP)):
|
||||
if scope == "ip" and ip is None:
|
||||
continue
|
||||
rows = db.scalars(select(LoginAttempt.ts).where(_condition(login, ip, scope), LoginAttempt.ts > now - WINDOW).order_by(LoginAttempt.ts.desc()).limit(limit)).all()
|
||||
counts[scope] = len(rows)
|
||||
if len(rows) >= limit:
|
||||
left = int((rows[0] + WINDOW - now).total_seconds()) + 1
|
||||
if left > worst:
|
||||
worst, why = left, scope
|
||||
return worst, why, counts
|
||||
|
||||
|
||||
def _distinct_logins(db: Session, ip: str) -> int:
|
||||
"""Число различных логинов, для которых была неудачная попытка с этого IP в окне (для diff записи session.locked)."""
|
||||
now = datetime.now(timezone.utc)
|
||||
return db.scalar(select(func.count(func.distinct(LoginAttempt.username))).where(LoginAttempt.client_ip == ip, LoginAttempt.ts > now - WINDOW)) or 0
|
||||
|
||||
|
||||
def _locked(retry: int) -> HTTPException:
|
||||
return HTTPException(429, {"message": f"Слишком много неудачных попыток входа. Повторите через {max(1, -(-retry // 60))} мин.", "retry_after_seconds": retry},
|
||||
headers={"Retry-After": str(retry)})
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenOut)
|
||||
def login(body: LoginIn, db: Session = Depends(get_db)):
|
||||
ctx = request_meta.get()
|
||||
ip = ctx["client_ip"] if ctx else None
|
||||
name = body.username.strip().lower()
|
||||
# сериализация попыток одного логина (изменение 024, находка №1): без этого параллельные запросы
|
||||
# проходят проверку блокировки одновременно, и лимит «N за окно» превращается в «N + степень параллелизма»
|
||||
db.execute(select(func.pg_advisory_xact_lock(func.hashtext(name))))
|
||||
retry, _, _ = _retry_after(db, name, ip)
|
||||
if retry: # блокировка: пароль не проверяем, в журнал не пишем (запись о блокировке уже есть)
|
||||
raise _locked(retry)
|
||||
user = db.scalar(select(User).where(User.username == body.username, User.is_active))
|
||||
if user is None:
|
||||
verify_password(body.password, _DUMMY_HASH)
|
||||
if user is None or not verify_password(body.password, user.password_hash):
|
||||
audit(db, ANONYMOUS, "session", None, "failed", body.username[:100], message="Неудачная попытка входа в UI")
|
||||
_, _, before = _retry_after(db, name, ip)
|
||||
db.add(LoginAttempt(client_ip=ip, username=name))
|
||||
db.flush()
|
||||
retry, why, after = _retry_after(db, name, ip)
|
||||
if retry and before[why] < after[why]: # именно этот запрос впервые пересёк лимит — запись пишем один раз
|
||||
attempts = after[why]
|
||||
diff = {"scope": why, "attempts": attempts, "retry_after_seconds": retry}
|
||||
if why == "ip":
|
||||
diff["distinct_logins"] = _distinct_logins(db, ip)
|
||||
audit(db, ANONYMOUS, "session", None, "locked", body.username[:100], diff,
|
||||
message=f"Вход заблокирован на {max(1, -(-retry // 60))} мин.: {attempts} неудачных попыток ({'по логину' if why == 'login' else 'с IP'})")
|
||||
elif before["login"] == 0: # в журнал — только первая неудача по этому логину в окне (не по IP: иначе перебор логинов с одного IP её не оставит)
|
||||
audit(db, ANONYMOUS, "session", None, "failed", body.username[:100], message="Неудачная попытка входа в UI")
|
||||
db.commit()
|
||||
if retry:
|
||||
raise _locked(retry)
|
||||
raise HTTPException(401, "Неверный логин или пароль")
|
||||
db.execute(delete(LoginAttempt).where(LoginAttempt.username == name))
|
||||
audit(db, user, "session", None, "login", user.username, message=f"Вход в UI: {user.username}")
|
||||
db.commit()
|
||||
return TokenOut(access_token=create_token(user))
|
||||
|
||||
@@ -13,15 +13,14 @@ from app.db import get_db
|
||||
from app.models import AuditLog, ClearAttempt, User
|
||||
from app.rotation import get_settings, rotate, save_settings
|
||||
from app.security import admin_user, current_user, verify_password
|
||||
from app.services import audit, commit
|
||||
from app.services import MAX_OFFSET, audit, commit, like_escape
|
||||
|
||||
router = APIRouter(dependencies=[Depends(current_user)], tags=["journal"])
|
||||
MAX_ATTEMPTS = 5
|
||||
LOCK_MINUTES = 10
|
||||
|
||||
|
||||
def _escape_like(q: str) -> str:
|
||||
return q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
_escape_like = like_escape
|
||||
|
||||
|
||||
def _filtered(stmt, event_type: str, entity_type: str, actor: str, date_from: date | None, date_to: date | None, q: str, client_ip: str = ""):
|
||||
@@ -57,7 +56,7 @@ def _filtered(stmt, event_type: str, entity_type: str, actor: str, date_from: da
|
||||
@router.get("/audit", response_model=s.Page[s.AuditOut])
|
||||
def list_audit(
|
||||
event_type: str = "", entity_type: str = "", actor: str = "", date_from: date | None = None, date_to: date | None = None,
|
||||
q: str = "", client_ip: str = "", limit: int = Query(100, le=500), offset: int = 0, db: Session = Depends(get_db),
|
||||
q: str = "", client_ip: str = "", limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0, le=MAX_OFFSET), db: Session = Depends(get_db),
|
||||
):
|
||||
stmt = _filtered(select(AuditLog), event_type, entity_type, actor, date_from, date_to, q, client_ip)
|
||||
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
|
||||
@@ -153,13 +152,19 @@ def _lock_state(db: Session, user_id: int) -> tuple[int, int]:
|
||||
def clear_journal(body: ClearIn, db: Session = Depends(get_db), user: User = Depends(admin_user)):
|
||||
used, retry = _lock_state(db, user.id)
|
||||
if retry:
|
||||
audit(db, user, "journal", None, "clear_locked", "clear", {"retry_after_seconds": retry}, message="Очистка журнала: попытка во время блокировки")
|
||||
db.commit()
|
||||
raise HTTPException(429, {"message": "Слишком много неверных попыток", "retry_after_seconds": retry})
|
||||
if not verify_password(body.password, user.password_hash):
|
||||
db.add(ClearAttempt(user_id=user.id))
|
||||
db.commit()
|
||||
used, retry = _lock_state(db, user.id)
|
||||
if retry:
|
||||
audit(db, user, "journal", None, "clear_locked", "clear", {"retry_after_seconds": retry}, message="Очистка журнала: неверный пароль, доступ заблокирован")
|
||||
db.commit()
|
||||
raise HTTPException(429, {"message": "Слишком много неверных попыток", "retry_after_seconds": retry})
|
||||
audit(db, user, "journal", None, "clear_failed", "clear", {"attempts_left": MAX_ATTEMPTS - used}, message="Очистка журнала: неверный пароль")
|
||||
db.commit()
|
||||
raise HTTPException(403, {"message": "Неверный пароль", "attempts_left": MAX_ATTEMPTS - used})
|
||||
deleted = db.execute(delete(AuditLog)).rowcount
|
||||
db.execute(delete(ClearAttempt).where(ClearAttempt.user_id == user.id))
|
||||
|
||||
+116
-43
@@ -1,7 +1,7 @@
|
||||
import ipaddress
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import String, and_, cast, func, or_, select, update
|
||||
from sqlalchemy import String, and_, cast, func, or_, select, text, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas as s
|
||||
@@ -9,7 +9,7 @@ from app.db import get_db
|
||||
from app.models import Address, AddressStatus, Device, Organization, Prefix, PrefixStatus, User, Vrf
|
||||
from app.security import admin_user, current_user
|
||||
from app.services import (
|
||||
MAX_CAPACITY, apply_update, audit, blockers, capacity, commit, count, flush, get_or_404, next_free, next_free_subnet, refuse_delete,
|
||||
MAX_CAPACITY, MAX_OFFSET, apply_update, audit, blockers, capacity, commit, count, flush, contains, free_page, get_or_404, network_role, next_free_address, next_free_subnet, refuse_delete,
|
||||
utilization,
|
||||
)
|
||||
|
||||
@@ -72,6 +72,7 @@ def _prefix_outs(db: Session, rows: list[Prefix]) -> list[s.PrefixOut]:
|
||||
usage = _usage(db, [r.id for r in rows])
|
||||
depths: dict[int, int] = {}
|
||||
caps: dict[int, int] = {}
|
||||
vrf_names = dict(db.execute(select(Vrf.id, Vrf.name).where(Vrf.id.in_(list({r.vrf_id for r in rows})))).all()) if rows else {}
|
||||
for org_id in {r.organization_id for r in rows}:
|
||||
depths.update(_depths(db, org_id))
|
||||
caps.update(_capacities(db, org_id))
|
||||
@@ -80,7 +81,7 @@ def _prefix_outs(db: Session, rows: list[Prefix]) -> list[s.PrefixOut]:
|
||||
used, stored = usage.get(r.id, (0, 0))
|
||||
cap = caps.get(r.id, capacity(str(r.prefix)))
|
||||
out.append(s.PrefixOut(
|
||||
id=r.id, organization_id=r.organization_id, vrf_id=r.vrf_id, vrf_name=r.vrf.name,
|
||||
id=r.id, organization_id=r.organization_id, vrf_id=r.vrf_id, vrf_name=vrf_names[r.vrf_id],
|
||||
prefix=str(r.prefix), family=ipaddress.ip_network(str(r.prefix)).version,
|
||||
description=r.description, status=r.status, parent_id=r.parent_id, depth=depths.get(r.id, 0),
|
||||
is_pool=r.is_pool, note=r.note, used=used, capacity=cap,
|
||||
@@ -111,6 +112,40 @@ def attach_to_tree(db: Session, p: Prefix, keep_parent: bool = False, exclude: f
|
||||
c.parent_id = p.id
|
||||
|
||||
|
||||
def _narrowest_prefix(db: Session, vrf_id: int, ip: str) -> Prefix | None:
|
||||
"""Самый узкий префикс VRF, содержащий адрес."""
|
||||
return db.scalar(select(Prefix).where(Prefix.vrf_id == vrf_id, Prefix.prefix.op(">>=")(ip)).order_by(func.masklen(Prefix.prefix).desc()).limit(1))
|
||||
|
||||
|
||||
def _unusable_after_rehome(db: Session, p: Prefix) -> list[tuple[str, str]]:
|
||||
"""(адрес, CIDR целевого префикса) для адресов VRF префикса p (его диапазон), которые при переносе в самый узкий
|
||||
целевой префикс (как в rehome_addresses) окажутся его адресом сети или broadcast — такой перенос запрещён
|
||||
(изменение 024, находка №2). Целевой префикс — не обязательно p: при переносе VRF им может оказаться уже существующий
|
||||
вложенный префикс целевого VRF, поэтому в паре возвращается именно он, а не p."""
|
||||
db.flush()
|
||||
rows = db.execute(text(
|
||||
"SELECT host(a2.address) AS ip, (SELECT x.prefix FROM prefixes x WHERE x.vrf_id = a2.vrf_id AND x.prefix >>= a2.address "
|
||||
" ORDER BY masklen(x.prefix) DESC LIMIT 1) AS cidr "
|
||||
"FROM addresses a2 WHERE a2.vrf_id = :vrf AND a2.address <<= :cidr"), {"vrf": p.vrf_id, "cidr": str(p.prefix)}).all()
|
||||
return [(ip, cidr) for ip, cidr in rows if network_role(ipaddress.ip_network(cidr), ipaddress.ip_address(ip))]
|
||||
|
||||
|
||||
def _unusable_message(bad: list[tuple[str, str]]) -> str:
|
||||
items = ", ".join(f"{ip} ({cidr})" for ip, cidr in bad)
|
||||
return f"Адреса {items} станут адресом сети/broadcast: освободите их или выберите другой префикс"
|
||||
|
||||
|
||||
def rehome_addresses(db: Session, p: Prefix) -> int:
|
||||
"""Приводит адреса диапазона префикса к правилу «адрес — в самом узком префиксе VRF»
|
||||
(новый вложенный забирает адреса родителя из своего диапазона; перенесённый префикс — адреса целевого VRF). Возвращает число перенесённых."""
|
||||
db.flush()
|
||||
return db.execute(text(
|
||||
"UPDATE addresses a SET prefix_id = t.best FROM ("
|
||||
" SELECT a2.id AS aid, (SELECT x.id FROM prefixes x WHERE x.vrf_id = a2.vrf_id AND x.prefix >>= a2.address ORDER BY masklen(x.prefix) DESC LIMIT 1) AS best "
|
||||
" FROM addresses a2 WHERE a2.vrf_id = :vrf AND a2.address <<= :cidr) t "
|
||||
"WHERE a.id = t.aid AND t.best IS NOT NULL AND t.best <> a.prefix_id"), {"vrf": p.vrf_id, "cidr": str(p.prefix)}).rowcount
|
||||
|
||||
|
||||
def _subtree(db: Session, root: Prefix) -> list[Prefix]:
|
||||
"""Префикс и все вложенные по цепочке parent_id."""
|
||||
result, frontier = [root], [root.id]
|
||||
@@ -130,12 +165,25 @@ def _move_to_vrf(db: Session, p: Prefix, vrf_id: int) -> tuple[dict, int]:
|
||||
taken = db.scalars(select(Prefix.prefix).where(Prefix.vrf_id == vrf.id, Prefix.prefix.in_([str(m.prefix) for m in subtree]))).all()
|
||||
if taken:
|
||||
raise HTTPException(409, f"В VRF «{vrf.name}» уже есть: {', '.join(str(x) for x in taken)}")
|
||||
clash = db.scalars(
|
||||
select(func.host(Address.address)).where(
|
||||
Address.prefix_id.in_([m.id for m in subtree]),
|
||||
Address.address.in_(select(Address.address).where(Address.vrf_id == vrf.id)),
|
||||
).limit(10)
|
||||
).all()
|
||||
if clash:
|
||||
raise HTTPException(409, f"В VRF «{vrf.name}» уже назначены адреса: {', '.join(clash)}")
|
||||
old_name = p.vrf.name
|
||||
for m in subtree:
|
||||
m.vrf = vrf
|
||||
p.parent_id = None
|
||||
db.flush()
|
||||
db.expire_all() # vrf_id адресов обновлён каскадом БД
|
||||
attach_to_tree(db, p, exclude=frozenset(m.id for m in subtree))
|
||||
bad = _unusable_after_rehome(db, p)
|
||||
if bad: # commit ещё не выполнялся — исключение уходит без частичных изменений (rollback при закрытии сессии)
|
||||
raise HTTPException(422, _unusable_message(bad))
|
||||
rehome_addresses(db, p)
|
||||
return {"vrf": f"{old_name} → {vrf.name}", "moved": len(subtree)}, len(subtree)
|
||||
|
||||
|
||||
@@ -143,7 +191,7 @@ def _move_to_vrf(db: Session, p: Prefix, vrf_id: int) -> tuple[dict, int]:
|
||||
def list_prefixes(
|
||||
organization_id: int | None = None, vrf_id: int | None = None, status: PrefixStatus | None = None,
|
||||
family: int | None = Query(None, ge=4, le=6), q: str = "",
|
||||
limit: int = Query(100, le=1000), offset: int = 0, db: Session = Depends(get_db),
|
||||
limit: int = Query(100, ge=1, le=1000), offset: int = Query(0, ge=0, le=MAX_OFFSET), db: Session = Depends(get_db),
|
||||
):
|
||||
stmt = select(Prefix)
|
||||
if organization_id:
|
||||
@@ -155,7 +203,7 @@ def list_prefixes(
|
||||
if family:
|
||||
stmt = stmt.where(func.family(Prefix.prefix) == (4 if family == 4 else 6))
|
||||
if q:
|
||||
stmt = stmt.where(or_(cast(Prefix.prefix, String).ilike(f"%{q.strip()}%"), Prefix.description.ilike(f"%{q.strip()}%")))
|
||||
stmt = stmt.where(or_(contains(cast(Prefix.prefix, String), q), contains(Prefix.description, q)))
|
||||
total = count(db, stmt)
|
||||
rows = db.scalars(stmt.order_by(Prefix.vrf_id, Prefix.prefix).limit(limit).offset(offset)).all()
|
||||
return s.Page(items=_prefix_outs(db, list(rows)), total=total)
|
||||
@@ -184,22 +232,34 @@ def create_prefix(body: s.PrefixIn, db: Session = Depends(get_db), user: User =
|
||||
db.add(p)
|
||||
flush(db, "Такой префикс уже есть в этом VRF")
|
||||
attach_to_tree(db, p, keep_parent=parent_id is not None)
|
||||
audit(db, user, "prefix", p, "created", str(p.prefix), {"vrf": vrf.name})
|
||||
bad = _unusable_after_rehome(db, p)
|
||||
if bad:
|
||||
msg = _unusable_message(bad) # до rollback: после него объект p истекает
|
||||
db.rollback()
|
||||
raise HTTPException(422, msg)
|
||||
moved = rehome_addresses(db, p)
|
||||
audit(db, user, "prefix", p, "created", str(p.prefix), {"vrf": vrf.name, **({"moved_addresses": moved} if moved else {})})
|
||||
commit(db, "Такой префикс уже есть в этом VRF")
|
||||
return _prefix_outs(db, [p])[0]
|
||||
|
||||
|
||||
def _busy_ranges(db: Session, p: Prefix) -> list[tuple[int, int]]:
|
||||
"""Занятые диапазоны внутри префикса: вложенные префиксы того же VRF (любой глубины) и адреса, записанные в самом префиксе."""
|
||||
busy = []
|
||||
for c in db.scalars(select(Prefix.prefix).where(Prefix.vrf_id == p.vrf_id, Prefix.id != p.id, Prefix.prefix.op("<<")(str(p.prefix)))):
|
||||
n = ipaddress.ip_network(str(c))
|
||||
busy.append((int(n.network_address), int(n.broadcast_address)))
|
||||
busy += [(int(ipaddress.ip_address(a)),) * 2 for a in db.scalars(select(func.host(Address.address)).where(Address.prefix_id == p.id))]
|
||||
return busy
|
||||
|
||||
|
||||
def _find_subnet(db: Session, parent: Prefix, length: int) -> tuple[str | None, int, int]:
|
||||
"""(свободный блок | None, min длина, max длина) для вложенного префикса в parent."""
|
||||
net = ipaddress.ip_network(str(parent.prefix))
|
||||
lo, hi = net.prefixlen + 1, net.max_prefixlen
|
||||
if not lo <= length <= hi:
|
||||
raise HTTPException(422, f"Размер вложенного префикса: от /{lo} до /{hi}" if lo <= hi else "Префикс нельзя дробить: это одиночный адрес")
|
||||
busy = [(int(n.network_address), int(n.broadcast_address)) for n in
|
||||
(ipaddress.ip_network(str(c)) for c in db.scalars(
|
||||
select(Prefix.prefix).where(Prefix.vrf_id == parent.vrf_id, Prefix.id != parent.id, Prefix.prefix.op("<<")(str(parent.prefix)))))]
|
||||
busy += [(int(ipaddress.ip_address(a)),) * 2 for a in db.scalars(select(func.host(Address.address)).where(Address.prefix_id == parent.id))]
|
||||
return next_free_subnet(str(parent.prefix), length, busy), lo, hi
|
||||
return next_free_subnet(str(parent.prefix), length, _busy_ranges(db, parent)), lo, hi
|
||||
|
||||
|
||||
@router.get("/prefixes/{id}/subnets/next", response_model=s.SubnetPreview)
|
||||
@@ -223,7 +283,8 @@ def allocate_subnet(id: int, body: s.SubnetNextIn, db: Session = Depends(get_db)
|
||||
db.add(p)
|
||||
flush(db, "Такой префикс уже есть в этом VRF, повторите запрос")
|
||||
attach_to_tree(db, p, keep_parent=True)
|
||||
audit(db, user, "prefix", p, "created", found, {"vrf": parent.vrf.name, "allocated_from": str(parent.prefix)})
|
||||
moved = rehome_addresses(db, p) # адреса родителя из выделенного блока (учтены и при выборе блока, но вдруг появились параллельно)
|
||||
audit(db, user, "prefix", p, "created", found, {"vrf": parent.vrf.name, "allocated_from": str(parent.prefix), **({"moved_addresses": moved} if moved else {})})
|
||||
commit(db, "Такой префикс уже есть в этом VRF, повторите запрос")
|
||||
return _prefix_outs(db, [p])[0]
|
||||
|
||||
@@ -248,7 +309,8 @@ def delete_prefix(id: int, force: bool = False, db: Session = Depends(get_db), u
|
||||
if used:
|
||||
refuse_delete(db, user, "prefix", p, str(p.prefix), "В префиксе есть адреса; удалите их или используйте force=true", {"addresses": used})
|
||||
db.execute(update(Prefix).where(Prefix.parent_id == id).values(parent_id=p.parent_id))
|
||||
audit(db, user, "prefix", p, "deleted", str(p.prefix))
|
||||
gone = count(db, select(Address.id).where(Address.prefix_id == id)) if force else 0
|
||||
audit(db, user, "prefix", p, "deleted", str(p.prefix), {"force": True, "addresses_deleted": gone} if gone else None)
|
||||
db.delete(p)
|
||||
commit(db)
|
||||
|
||||
@@ -271,10 +333,11 @@ def _check_device(db: Session, prefix: Prefix, device_id: int | None):
|
||||
|
||||
@router.get("/prefixes/{id}/addresses", response_model=s.AddressPage)
|
||||
def list_addresses(
|
||||
id: int, status: str = "", q: str = "", limit: int = Query(100, le=500), offset: int = 0,
|
||||
id: int, status: str = "", q: str = "", limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0, le=MAX_OFFSET),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""status: assigned | reserved | deprecated | free | пусто (все; свободные подмешиваются для малых подсетей)."""
|
||||
"""status: assigned | reserved | deprecated | free | пусто (все; свободные подмешиваются для малых подсетей).
|
||||
Пагинация — в SQL; страница «свободных» считается арифметически (без перебора адресов подсети)."""
|
||||
p = get_or_404(db, Prefix, id, "Префикс")
|
||||
cap = capacity(str(p.prefix))
|
||||
counts = dict(db.execute(select(Address.status, func.count()).where(Address.prefix_id == id).group_by(Address.status)).all())
|
||||
@@ -285,39 +348,47 @@ def list_addresses(
|
||||
)
|
||||
if status and status not in {"free", *(x.value for x in AddressStatus)}:
|
||||
raise HTTPException(422, "Неизвестный статус")
|
||||
|
||||
stmt = select(Address, Device.name).outerjoin(Device, Device.id == Address.device_id).where(Address.prefix_id == id)
|
||||
if status and status != "free":
|
||||
stmt = stmt.where(Address.status == AddressStatus(status))
|
||||
if q:
|
||||
like = f"%{q.strip()}%"
|
||||
stmt = stmt.where(or_(func.host(Address.address).ilike(like), Address.dns_name.ilike(like), Address.description.ilike(like)))
|
||||
rows = [_addr_out(a, dn) for a, dn in db.execute(stmt.order_by(Address.address)).all()] if status != "free" else []
|
||||
|
||||
net = ipaddress.ip_network(str(p.prefix))
|
||||
want_free = status == "free" or (not status and not q and cap <= FREE_LISTING_LIMIT)
|
||||
if want_free:
|
||||
used = {ipaddress.ip_address(s.ip_text(a)) for a in db.scalars(select(Address.address).where(Address.prefix_id == id))}
|
||||
hosts = net.hosts() if net.version == 4 and net.prefixlen <= 30 else iter(net)
|
||||
need = offset + limit if status == "free" else cap
|
||||
free = []
|
||||
for ip in hosts:
|
||||
if ip not in used:
|
||||
free.append(s.AddressOut(id=None, prefix_id=id, address=str(ip), status="free"))
|
||||
if len(free) >= need:
|
||||
break
|
||||
rows = sorted(rows + free, key=lambda r: ipaddress.ip_address(r.address))
|
||||
total = summary.free if status == "free" else (len(rows) if want_free or q or status else stored)
|
||||
return s.AddressPage(items=rows[offset:offset + limit], total=total, summary=summary)
|
||||
|
||||
def occupied() -> list[int]:
|
||||
return sorted(int(ipaddress.ip_address(s.ip_text(a))) for a in db.scalars(select(Address.address).where(Address.prefix_id == id)))
|
||||
|
||||
def free_rows(ips: list[str]) -> list[s.AddressOut]:
|
||||
return [s.AddressOut(id=None, prefix_id=id, address=ip, status="free") for ip in ips]
|
||||
|
||||
if status == "free":
|
||||
return s.AddressPage(items=free_rows(free_page(net, occupied(), offset, limit)), total=summary.free, summary=summary)
|
||||
|
||||
flt = [Address.prefix_id == id]
|
||||
if status:
|
||||
flt.append(Address.status == AddressStatus(status))
|
||||
if q:
|
||||
flt.append(or_(contains(func.host(Address.address), q), contains(Address.dns_name, q), contains(Address.description, q)))
|
||||
stmt = select(Address, Device.name).outerjoin(Device, Device.id == Address.device_id).where(*flt)
|
||||
mixed = not status and not q and cap <= FREE_LISTING_LIMIT # малая подсеть: занятые и свободные вперемешку (список ограничен размером подсети)
|
||||
if mixed:
|
||||
rows = [_addr_out(a, dn) for a, dn in db.execute(stmt.order_by(Address.address)).all()]
|
||||
rows = sorted(rows + free_rows(free_page(net, occupied(), 0, cap)), key=lambda r: ipaddress.ip_address(r.address))
|
||||
return s.AddressPage(items=rows[offset:offset + limit], total=len(rows), summary=summary)
|
||||
total = count(db, select(Address.id).where(*flt))
|
||||
page = db.execute(stmt.order_by(Address.address).limit(limit).offset(offset)).all()
|
||||
return s.AddressPage(items=[_addr_out(a, dn) for a, dn in page], total=total, summary=summary)
|
||||
|
||||
|
||||
@router.post("/prefixes/{id}/addresses", response_model=s.AddressOut, status_code=201)
|
||||
def create_address(id: int, body: s.AddressIn, db: Session = Depends(get_db), user: User = Depends(admin_user)):
|
||||
p = get_or_404(db, Prefix, id, "Префикс")
|
||||
if ipaddress.ip_address(body.address) not in ipaddress.ip_network(str(p.prefix)):
|
||||
net, ip = ipaddress.ip_network(str(p.prefix)), ipaddress.ip_address(body.address)
|
||||
if ip not in net:
|
||||
raise HTTPException(422, f"Адрес {body.address} не принадлежит префиксу {p.prefix}")
|
||||
role = network_role(net, ip)
|
||||
if role:
|
||||
raise HTTPException(422, f"Адрес {body.address} — {'адрес сети' if role == 'network' else 'broadcast'} префикса {p.prefix}, назначить его нельзя")
|
||||
narrowest = _narrowest_prefix(db, p.vrf_id, body.address)
|
||||
if narrowest is not None and narrowest.id != p.id: # адрес хранится в самом узком префиксе VRF
|
||||
raise HTTPException(422, f"Адрес {body.address} принадлежит вложенному префиксу {narrowest.prefix}, назначьте его там")
|
||||
_check_device(db, p, body.device_id)
|
||||
a = Address(prefix_id=id, **body.model_dump())
|
||||
a = Address(prefix_id=id, vrf_id=p.vrf_id, **body.model_dump())
|
||||
db.add(a)
|
||||
flush(db, "Адрес уже есть в этом префиксе")
|
||||
audit(db, user, "address", a, "assigned" if a.status == AddressStatus.assigned else "created", body.address)
|
||||
@@ -331,15 +402,17 @@ def allocate_next(
|
||||
id: int, body: s.AddressUpdate | None = None, db: Session = Depends(get_db), user: User = Depends(admin_user)
|
||||
):
|
||||
"""Автоназначение первого свободного адреса; только для префиксов с флагом is_pool."""
|
||||
p = get_or_404(db, Prefix, id, "Префикс")
|
||||
p = db.scalar(select(Prefix).where(Prefix.id == id).with_for_update()) # параллельные запросы получают разные адреса
|
||||
if p is None:
|
||||
raise HTTPException(404, "Префикс не найден")
|
||||
if not p.is_pool:
|
||||
raise HTTPException(422, "Префикс не является пулом для автоназначения")
|
||||
ip = next_free(db, id, str(p.prefix))
|
||||
ip = next_free_address(ipaddress.ip_network(str(p.prefix)), _busy_ranges(db, p)) # вложенные префиксы и адрес сети/broadcast пропускаются
|
||||
if ip is None:
|
||||
raise HTTPException(409, "В префиксе нет свободных адресов")
|
||||
data = body.model_dump(exclude_unset=True, exclude_none=True) if body else {}
|
||||
_check_device(db, p, data.get("device_id"))
|
||||
a = Address(prefix_id=id, address=ip, **data)
|
||||
a = Address(prefix_id=id, vrf_id=p.vrf_id, address=ip, **data)
|
||||
db.add(a)
|
||||
flush(db, "Адрес уже занят, повторите запрос")
|
||||
audit(db, user, "address", a, "assigned", ip)
|
||||
|
||||
+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"])
|
||||
|
||||
+22
-6
@@ -4,6 +4,8 @@
|
||||
свою учётную запись и последнего активного администратора; зарезервированные логины `system`
|
||||
и `anonymous` запрещены — журнал различает по ним служебные события (`actor_of` в app/services.py).
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -11,12 +13,19 @@ from sqlalchemy.orm import Session
|
||||
from app import schemas as s
|
||||
from app.db import get_db
|
||||
from app.models import Role, User
|
||||
from app.security import admin_user, current_user, hash_password, verify_password
|
||||
from app.services import apply_update, audit, commit, count, flush, get_or_404, refuse_delete
|
||||
from app.security import admin_user, create_token, current_user, hash_password, verify_password
|
||||
from app.services import MAX_OFFSET, apply_update, audit, commit, contains, count, flush, get_or_404, refuse_delete
|
||||
|
||||
router = APIRouter(dependencies=[Depends(current_user)], tags=["users"])
|
||||
|
||||
|
||||
USERS_ADMIN_LOCK = 703002 # advisory lock: изменения прав администраторов идут по одному (иначе двое отключат друг друга одновременно)
|
||||
|
||||
|
||||
def _lock_admins(db: Session) -> None:
|
||||
db.execute(select(func.pg_advisory_xact_lock(USERS_ADMIN_LOCK)))
|
||||
|
||||
|
||||
def _other_active_admins(db: Session, user_id: int) -> int:
|
||||
"""Активные администраторы, кроме указанного: 0 — система осталась бы без прав записи."""
|
||||
return count(db, select(User.id).where(User.role == Role.admin, User.is_active, User.id != user_id))
|
||||
@@ -24,12 +33,12 @@ def _other_active_admins(db: Session, user_id: int) -> int:
|
||||
|
||||
@router.get("/users", response_model=s.Page[s.UserOut])
|
||||
def list_users(
|
||||
q: str = "", limit: int = Query(100, le=500), offset: int = 0,
|
||||
q: str = "", limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0, le=MAX_OFFSET),
|
||||
db: Session = Depends(get_db), admin: User = Depends(admin_user),
|
||||
):
|
||||
stmt = select(User)
|
||||
if q.strip():
|
||||
stmt = stmt.where(User.username.ilike(f"%{q.strip()}%"))
|
||||
stmt = stmt.where(contains(User.username, q))
|
||||
total = count(db, stmt)
|
||||
rows = db.scalars(stmt.order_by(User.username).limit(limit).offset(offset)).all()
|
||||
return s.Page(items=[s.UserOut.model_validate(u) for u in rows], total=total)
|
||||
@@ -50,8 +59,12 @@ def create_user(body: s.UserIn, db: Session = Depends(get_db), admin: User = Dep
|
||||
|
||||
@router.patch("/users/{id}", response_model=s.UserOut)
|
||||
def update_user(id: int, body: s.UserUpdate, db: Session = Depends(get_db), admin: User = Depends(admin_user)):
|
||||
u = get_or_404(db, User, id, "Пользователь")
|
||||
data = body.model_dump(exclude_unset=True, exclude_none=True)
|
||||
if "role" in data or "is_active" in data:
|
||||
_lock_admins(db) # до чтения пользователя и подсчёта администраторов
|
||||
u = get_or_404(db, User, id, "Пользователь")
|
||||
if u.id == admin.id and "password" in data:
|
||||
raise HTTPException(422, "Свой пароль меняется через /users/me/password (с подтверждением текущего)")
|
||||
pwd = data.pop("password", None)
|
||||
role, is_active = data.get("role", u.role), data.get("is_active", u.is_active)
|
||||
loses_admin = u.role == Role.admin and u.is_active and (role != Role.admin or not is_active)
|
||||
@@ -62,6 +75,7 @@ def update_user(id: int, body: s.UserUpdate, db: Session = Depends(get_db), admi
|
||||
changed = apply_update(u, data)
|
||||
if pwd:
|
||||
u.password_hash = hash_password(pwd)
|
||||
u.password_changed_at = datetime.now(timezone.utc)
|
||||
if changed:
|
||||
audit(db, admin, "user", u, "updated", u.username, changed)
|
||||
if pwd:
|
||||
@@ -72,6 +86,7 @@ def update_user(id: int, body: s.UserUpdate, db: Session = Depends(get_db), admi
|
||||
|
||||
@router.delete("/users/{id}", status_code=204)
|
||||
def delete_user(id: int, db: Session = Depends(get_db), admin: User = Depends(admin_user)):
|
||||
_lock_admins(db)
|
||||
u = get_or_404(db, User, id, "Пользователь")
|
||||
if u.id == admin.id:
|
||||
refuse_delete(db, admin, "user", u, u.username, "Нельзя удалить свою учётную запись")
|
||||
@@ -88,6 +103,7 @@ def change_own_password(body: s.PasswordChange, db: Session = Depends(get_db), u
|
||||
if not verify_password(body.current_password, user.password_hash):
|
||||
raise HTTPException(403, "Неверный текущий пароль")
|
||||
user.password_hash = hash_password(body.new_password)
|
||||
user.password_changed_at = datetime.now(timezone.utc)
|
||||
audit(db, user, "user", user, "password_reset", user.username, message=f"{user.username}: пароль изменён пользователем")
|
||||
commit(db)
|
||||
return {"ok": True}
|
||||
return {"ok": True, "access_token": create_token(user)} # прежние токены недействительны — текущая сессия получает новый
|
||||
Reference in new issue
Block a user