Ревью кодовой базы (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>
110 lines
6.5 KiB
Python
110 lines
6.5 KiB
Python
"""Пользователи: учётные записи UI (роли admin/viewer), смена своего пароля.
|
||
|
||
Правила: логин после создания не меняется (он же `sub` в JWT), нельзя отключить/понизить/удалить
|
||
свою учётную запись и последнего активного администратора; зарезервированные логины `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
|
||
|
||
from app import schemas as s
|
||
from app.db import get_db
|
||
from app.models import Role, User
|
||
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))
|
||
|
||
|
||
@router.get("/users", response_model=s.Page[s.UserOut])
|
||
def list_users(
|
||
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(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)
|
||
|
||
|
||
@router.post("/users", response_model=s.UserOut, status_code=201)
|
||
def create_user(body: s.UserIn, db: Session = Depends(get_db), admin: User = Depends(admin_user)):
|
||
# логин уникален без учёта регистра: в токене и в журнале он должен опознаваться однозначно
|
||
if db.scalar(select(User.id).where(func.lower(User.username) == body.username.lower())):
|
||
raise HTTPException(409, "Пользователь с таким логином уже существует")
|
||
u = User(username=body.username, password_hash=hash_password(body.password), role=body.role, is_active=body.is_active)
|
||
db.add(u)
|
||
flush(db, "Пользователь с таким логином уже существует")
|
||
audit(db, admin, "user", u, "created", u.username)
|
||
commit(db, "Пользователь с таким логином уже существует")
|
||
return u
|
||
|
||
|
||
@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)):
|
||
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)
|
||
if u.id == admin.id and (role != Role.admin or not is_active):
|
||
raise HTTPException(409, "Нельзя отключить или понизить свою учётную запись")
|
||
if loses_admin and _other_active_admins(db, u.id) == 0:
|
||
raise HTTPException(409, "Нельзя отключить или понизить единственного активного администратора")
|
||
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:
|
||
audit(db, admin, "user", u, "password_reset", u.username, message=f"Пользователь {u.username}: пароль изменён администратором")
|
||
commit(db)
|
||
return u
|
||
|
||
|
||
@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, "Нельзя удалить свою учётную запись")
|
||
if u.role == Role.admin and u.is_active and _other_active_admins(db, u.id) == 0:
|
||
refuse_delete(db, admin, "user", u, u.username, "Нельзя удалить единственного активного администратора")
|
||
audit(db, admin, "user", u, "deleted", u.username)
|
||
db.delete(u)
|
||
commit(db)
|
||
|
||
|
||
@router.post("/users/me/password")
|
||
def change_own_password(body: s.PasswordChange, db: Session = Depends(get_db), user: User = Depends(current_user)):
|
||
"""Смена своего пароля: доступна любой роли, требует текущий пароль."""
|
||
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, "access_token": create_token(user)} # прежние токены недействительны — текущая сессия получает новый
|