Files
ipam_control/app/api/v1/users.py
T
ayurishchevandClaude Opus 5.5 744a025960 Задачи 032-033: ролевая модель с привязкой к организации, исправления по ревью
Пентест (docs/reviews/2026-09-27-pentest.md) и план 031 (Swagger, TLS) — план, не реализован.
032 Роль superadmin (без организации) и привязка admin/viewer к одной организации:
    users.organization_id + CHECK, audit_log.organization_id (миграции 0010-0012);
    require_org/scope_org во всех чтениях и записях, журнал и «Обзор» в границах
    организации; пользователи, организации, типы устройств, настройки журнала — только superadmin.
033 Исправление находок ревью 032 (docs/reviews/2026-09-27-changes-032-review.md,
    docs/reviews/2026-09-27-codebase-review.md):
    - FK audit_log.organization_id ON DELETE SET NULL (миграция 0013) — удаление организаций;
    - проверка организации в предпросмотре подсети;
    - инвариант «роль — организация» по итоговому состоянию (повышение снимает организацию,
      понижение требует её), 422/404 вместо обезличенных 409;
    - одинаковый 404 для чужих и несуществующих объектов (VRF, устройство, parent_id, оператор);
    - отказы удаления в журнале организации, счётчики типов в пределах организации;
    - UI: живое поле «Организация» в диалоге пользователя, бейдж superadmin; род в текстах 404.
README актуализирован под ролевую модель.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 11:26:57 +03:00

125 lines
8.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Пользователи: учётные записи UI (роли superadmin/admin/viewer), смена своего пароля.
Правила: логин после создания не меняется (он же `sub` в JWT), нельзя отключить/понизить/удалить
свою учётную запись и последнего активного суперадминистратора; зарезервированные логины `system`
и `anonymous` запрещены — журнал различает по ним служебные события (`actor_of` в app/services.py).
Управление пользователями — только superadmin (изменение 032).
"""
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 Organization, Role, User
from app.security import admin_user, create_token, current_user, hash_password, superadmin_user, 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_LOCK = 703002 # advisory lock: изменения прав суперадминистраторов идут по одному (иначе двое отключат друг друга одновременно)
def _lock_users(db: Session) -> None:
db.execute(select(func.pg_advisory_xact_lock(USERS_LOCK)))
def _other_active_superadmins(db: Session, user_id: int) -> int:
"""Активные суперадминистраторы, кроме указанного: 0 — система осталась бы без прав записи (изменение 032)."""
return count(db, select(User.id).where(User.role == Role.superadmin, 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), user: User = Depends(superadmin_user), # изменение 032
):
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), user: User = Depends(superadmin_user)): # изменение 032
# логин уникален без учёта регистра: в токене и в журнале он должен опознаваться однозначно
if db.scalar(select(User.id).where(func.lower(User.username) == body.username.lower())):
raise HTTPException(409, "Пользователь с таким логином уже существует")
if body.organization_id is not None:
get_or_404(db, Organization, body.organization_id, "Организация") # изменение 033, находка №10
u = User(username=body.username, password_hash=hash_password(body.password), role=body.role, organization_id=body.organization_id, is_active=body.is_active) # изменение 032
db.add(u)
flush(db) # изменение 033, находка №10: логин уже проверен выше, гонку покрывает CONFLICT_MSG
audit(db, user, "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), user: User = Depends(superadmin_user)): # изменение 032
data = body.model_dump(exclude_unset=True, exclude_none=True)
if "role" in data or "is_active" in data:
_lock_users(db) # до чтения пользователя и подсчёта суперадминистраторов (изменение 032)
u = get_or_404(db, User, id, "Пользователь")
if u.id == user.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_superadmin = u.role == Role.superadmin and u.is_active and (role != Role.superadmin or not is_active) # изменение 032
if u.id == user.id and (role != Role.superadmin or not is_active): # изменение 032
raise HTTPException(409, "Нельзя отключить или понизить свою учётную запись")
if loses_superadmin and _other_active_superadmins(db, u.id) == 0: # изменение 032
raise HTTPException(409, "Нельзя отключить или понизить единственного активного суперадминистратора")
# реконсиляция «роль — организация» по итоговому состоянию, до apply_update (изменение 033, находка №3)
org_id_passed = "organization_id" in data # exclude_none=True выше уже отбросил явный null
if role == Role.superadmin:
if org_id_passed:
raise HTTPException(422, "Суперадминистратор не привязан к организации")
data["organization_id"] = None # повышение снимает организацию само
else:
org_f = data.get("organization_id", u.organization_id)
if org_f is None and (is_active or "role" in data or org_id_passed):
raise HTTPException(422, "Администратор и просмотрщик должны быть привязаны к организации")
if org_id_passed:
get_or_404(db, Organization, data["organization_id"], "Организация")
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, user, "user", u, "updated", u.username, changed)
if pwd:
audit(db, user, "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), user: User = Depends(superadmin_user)): # изменение 032
_lock_users(db)
u = get_or_404(db, User, id, "Пользователь")
if u.id == user.id:
refuse_delete(db, user, "user", u, u.username, "Нельзя удалить свою учётную запись")
if u.role == Role.superadmin and u.is_active and _other_active_superadmins(db, u.id) == 0: # изменение 032
refuse_delete(db, user, "user", u, u.username, "Нельзя удалить единственного активного суперадминистратора")
audit(db, user, "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)} # прежние токены недействительны — текущая сессия получает новый