Пентест (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>
125 lines
8.2 KiB
Python
125 lines
8.2 KiB
Python
"""Пользователи: учётные записи 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)} # прежние токены недействительны — текущая сессия получает новый
|