Задачи 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>
This commit is contained in:
ayurishchevandClaude Opus 5.5 committed 2026-09-27 11:26:57 +03:00
1 parent 5210ba3333
commit 744a025960
28 files changed
+1283 -164

No files matched your search

+19 -11
View File
@@ -12,8 +12,8 @@ from app import schemas as s
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 MAX_OFFSET, audit, commit, like_escape
from app.security import admin_user, current_user, superadmin_user, verify_password
from app.services import MAX_OFFSET, audit, commit, like_escape, require_org, scope_org # изменение 032
router = APIRouter(dependencies=[Depends(current_user)], tags=["journal"])
MAX_ATTEMPTS = 5
@@ -56,9 +56,10 @@ 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, ge=1, le=500), offset: int = Query(0, ge=0, le=MAX_OFFSET), 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), user: User = Depends(current_user), # изменение 032
):
stmt = _filtered(select(AuditLog), event_type, entity_type, actor, date_from, date_to, q, client_ip)
stmt = scope_org(stmt, AuditLog.organization_id, user) # изменение 032: фильтр по организации
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
rows = db.scalars(stmt.order_by(AuditLog.id.desc()).limit(limit).offset(offset)).all()
return s.Page(items=[s.AuditOut.from_row(r) for r in rows], total=total)
@@ -72,9 +73,11 @@ class Summary(BaseModel):
@router.get("/audit/summary", response_model=Summary)
def summary(db: Session = Depends(get_db)):
def summary(db: Session = Depends(get_db), user: User = Depends(current_user)): # изменение 032
cfg = get_settings(db)
total, oldest = db.execute(select(func.count(), func.min(AuditLog.ts))).one()
stmt = select(func.count(), func.min(AuditLog.ts)).select_from(AuditLog)
stmt = scope_org(stmt, AuditLog.organization_id, user) # изменение 032: фильтр по организации
total, oldest = db.execute(stmt).one()
return Summary(total=total, oldest_ts=oldest, **cfg)
@@ -85,9 +88,13 @@ class Facets(BaseModel):
@router.get("/audit/facets", response_model=Facets)
def facets(db: Session = Depends(get_db)):
pairs = db.execute(select(AuditLog.entity_type, AuditLog.action).distinct().order_by(AuditLog.entity_type, AuditLog.action)).all()
users = db.scalars(select(AuditLog.username).distinct().order_by(AuditLog.username)).all()
def facets(db: Session = Depends(get_db), user: User = Depends(current_user)): # изменение 032
stmt = select(AuditLog.entity_type, AuditLog.action).distinct()
stmt = scope_org(stmt, AuditLog.organization_id, user) # изменение 032: фильтр по организации
pairs = db.execute(stmt.order_by(AuditLog.entity_type, AuditLog.action)).all()
stmt2 = select(AuditLog.username).distinct()
stmt2 = scope_org(stmt2, AuditLog.organization_id, user) # изменение 032: фильтр по организации
users = db.scalars(stmt2.order_by(AuditLog.username)).all()
return Facets(
event_types=[f"{e}.{a}" for e, a in pairs], entity_types=sorted({e for e, _ in pairs}),
actors=[u if u in ("system", "anonymous") else f"ui:{u}" for u in users],
@@ -95,10 +102,11 @@ def facets(db: Session = Depends(get_db)):
@router.get("/audit/{uid}", response_model=s.AuditOut)
def get_entry(uid: uuid.UUID, db: Session = Depends(get_db)):
def get_entry(uid: uuid.UUID, db: Session = Depends(get_db), user: User = Depends(current_user)): # изменение 032
row = db.scalar(select(AuditLog).where(AuditLog.uid == uid))
if row is None:
raise HTTPException(404, "Запись не найдена")
require_org(user, row.organization_id, "Запись журнала") # изменение 032
return s.AuditOut.from_row(row)
@@ -118,7 +126,7 @@ class SettingsSaved(JournalSettings):
@router.put("/journal/settings", response_model=SettingsSaved)
def update_settings(body: JournalSettings, db: Session = Depends(get_db), user: User = Depends(admin_user)):
def update_settings(body: JournalSettings, db: Session = Depends(get_db), user: User = Depends(superadmin_user)): # изменение 032: только superadmin
old = get_settings(db)
new = body.model_dump()
save_settings(db, new)
@@ -149,7 +157,7 @@ def _lock_state(db: Session, user_id: int) -> tuple[int, int]:
@router.post("/journal/clear")
def clear_journal(body: ClearIn, db: Session = Depends(get_db), user: User = Depends(admin_user)):
def clear_journal(body: ClearIn, db: Session = Depends(get_db), user: User = Depends(superadmin_user)): # изменение 032: только superadmin
used, retry = _lock_state(db, user.id)
if retry:
audit(db, user, "journal", None, "clear_locked", "clear", {"retry_after_seconds": retry}, message="Очистка журнала: попытка во время блокировки")
+16 -7
View File
@@ -6,9 +6,9 @@ from sqlalchemy.orm import Session
from app import schemas as s
from app.api.v1.prefixes import _prefix_outs
from app.db import get_db
from app.models import Address, AddressStatus, AuditLog, Prefix, PrefixStatus, Vrf
from app.models import Address, AddressStatus, AuditLog, Prefix, PrefixStatus, User, Vrf
from app.security import current_user
from app.services import capacity, utilization
from app.services import capacity, scope_org, utilization # изменение 032
router = APIRouter(dependencies=[Depends(current_user)])
@@ -39,14 +39,17 @@ def _ipv4_roots(db: Session):
@router.get("/overview", response_model=Overview, tags=["overview"])
def overview(db: Session = Depends(get_db)):
active = db.scalars(select(Prefix).where(Prefix.status == PrefixStatus.active)).all()
def overview(db: Session = Depends(get_db), user: User = Depends(current_user)): # изменение 032
stmt_prefix = select(Prefix).where(Prefix.status == PrefixStatus.active)
stmt_prefix = scope_org(stmt_prefix, Prefix.organization_id, user) # изменение 032
active = db.scalars(stmt_prefix).all()
outs = _prefix_outs(db, list(active))
parents = {p.parent_id for p in outs if p.parent_id}
leaves4 = [p for p in outs if p.id not in parents and p.family == 4] # top_prefixes — как раньше, по листьям
top = sorted((p for p in leaves4 if p.capacity > 1), key=lambda p: p.utilization, reverse=True)[:4]
roots_stmt = _ipv4_roots(db)
roots_stmt = scope_org(roots_stmt, Prefix.organization_id, user) # изменение 032
roots = db.execute(roots_stmt).all()
cap = sum(capacity(str(r.prefix)) for r in roots)
roots_cte = roots_stmt.cte("overview_roots")
@@ -58,10 +61,16 @@ def overview(db: Session = Depends(get_db)):
).all())
assigned = counts.get(AddressStatus.assigned, 0)
reserved = counts.get(AddressStatus.reserved, 0)
recent = db.scalars(select(AuditLog).order_by(AuditLog.id.desc()).limit(4)).all()
stmt_audit = select(AuditLog)
stmt_audit = scope_org(stmt_audit, AuditLog.organization_id, user) # изменение 032: фильтр по организации
recent = db.scalars(stmt_audit.order_by(AuditLog.id.desc()).limit(4)).all()
stmt_prefix_count = select(func.count()).select_from(Prefix)
stmt_prefix_count = scope_org(stmt_prefix_count, Prefix.organization_id, user) # изменение 032
stmt_vrf_count = select(func.count()).select_from(Vrf)
stmt_vrf_count = scope_org(stmt_vrf_count, Vrf.organization_id, user) # изменение 032
return Overview(
prefixes=db.scalar(select(func.count()).select_from(Prefix)) or 0,
vrfs=db.scalar(select(func.count()).select_from(Vrf)) or 0,
prefixes=db.scalar(stmt_prefix_count) or 0,
vrfs=db.scalar(stmt_vrf_count) or 0,
assigned=assigned, capacity=cap, utilization=utilization(assigned, cap), reserved=reserved,
top_prefixes=top, recent_changes=[s.AuditOut.from_row(r) for r in recent],
)
+44 -22
View File
@@ -10,7 +10,7 @@ from app.models import Address, AddressStatus, Device, Organization, Prefix, Pre
from app.security import admin_user, current_user
from app.services import (
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,
require_org, scope_org, utilization, # изменение 032
)
router = APIRouter(dependencies=[Depends(current_user)], tags=["prefixes"])
@@ -170,9 +170,10 @@ def _subtree(db: Session, root: Prefix) -> list[Prefix]:
return result
def _move_to_vrf(db: Session, p: Prefix, vrf_id: int) -> tuple[dict, int]:
def _move_to_vrf(db: Session, p: Prefix, vrf_id: int, user: User) -> tuple[dict, int]:
"""Переносит префикс с поддеревом в другой VRF той же организации; возвращает (diff, число префиксов)."""
vrf = get_or_404(db, Vrf, vrf_id, "VRF")
require_org(user, vrf.organization_id, "VRF") # изменение 033, находка №11
if vrf.organization_id != p.organization_id:
raise HTTPException(422, "Целевой VRF принадлежит другой организации")
subtree = _subtree(db, p)
@@ -205,11 +206,14 @@ 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, ge=1, le=1000), offset: int = Query(0, ge=0, le=MAX_OFFSET), 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), user: User = Depends(current_user), # изменение 032
):
stmt = select(Prefix)
if organization_id:
require_org(user, organization_id, "Организация") # изменение 032
stmt = stmt.where(Prefix.organization_id == organization_id)
else:
stmt = scope_org(stmt, Prefix.organization_id, user) # изменение 032
if vrf_id:
stmt = stmt.where(Prefix.vrf_id == vrf_id)
if status:
@@ -224,14 +228,18 @@ def list_prefixes(
@router.get("/prefixes/{id}", response_model=s.PrefixOut)
def get_prefix(id: int, db: Session = Depends(get_db)):
return _prefix_outs(db, [get_or_404(db, Prefix, id, "Префикс")])[0]
def get_prefix(id: int, db: Session = Depends(get_db), user: User = Depends(current_user)): # изменение 032
p = get_or_404(db, Prefix, id, "Префикс")
require_org(user, p.organization_id, "Префикс") # изменение 032
return _prefix_outs(db, [p])[0]
@router.post("/prefixes", response_model=s.PrefixOut, status_code=201)
def create_prefix(body: s.PrefixIn, db: Session = Depends(get_db), user: User = Depends(admin_user)):
require_org(user, body.organization_id, "Организация") # изменение 033, находка №11: до _lock_vrf и чтения чужого VRF
_lock_vrf(db, body.vrf_id) # до любых чтений дерева VRF (изменение 029)
vrf = get_or_404(db, Vrf, body.vrf_id, "VRF")
require_org(user, vrf.organization_id, "VRF") # изменение 033, находка №11
if vrf.organization_id != body.organization_id:
raise HTTPException(422, "VRF принадлежит другой организации")
get_or_404(db, Organization, body.organization_id, "Организация")
@@ -241,6 +249,7 @@ def create_prefix(body: s.PrefixIn, db: Session = Depends(get_db), user: User =
parent_id = body.parent_id
if parent_id is not None:
parent = get_or_404(db, Prefix, parent_id, "Родительский префикс")
require_org(user, parent.organization_id, "Родительский префикс") # изменение 033, находка №11
if parent.vrf_id != vrf.id or not ipaddress.ip_network(body.prefix).subnet_of(ipaddress.ip_network(str(parent.prefix))):
raise HTTPException(422, "Родительский префикс должен содержать новый и быть в том же VRF")
p = Prefix(**{**body.model_dump(), "parent_id": parent_id})
@@ -253,7 +262,7 @@ def create_prefix(body: s.PrefixIn, db: Session = Depends(get_db), user: User =
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 {})})
audit(db, user, "prefix", p, "created", str(p.prefix), {"vrf": vrf.name, **({"moved_addresses": moved} if moved else {})}, organization_id=body.organization_id) # изменение 032: organization_id
commit(db, "Такой префикс уже есть в этом VRF")
return _prefix_outs(db, [p])[0]
@@ -278,9 +287,11 @@ def _find_subnet(db: Session, parent: Prefix, length: int) -> tuple[str | None,
@router.get("/prefixes/{id}/subnets/next", response_model=s.SubnetPreview)
def preview_subnet(id: int, length: int = Query(ge=1, le=128), db: Session = Depends(get_db)):
def preview_subnet(id: int, length: int = Query(ge=1, le=128), db: Session = Depends(get_db), user: User = Depends(current_user)): # изменение 033
"""Предпросмотр: какой блок будет выделен, без создания."""
found, lo, hi = _find_subnet(db, get_or_404(db, Prefix, id, "Префикс"), length)
p = get_or_404(db, Prefix, id, "Префикс")
require_org(user, p.organization_id, "Префикс") # изменение 033, находка №9: не хватало проверки организации
found, lo, hi = _find_subnet(db, p, length)
return s.SubnetPreview(prefix=found, length_min=lo, length_max=hi)
@@ -288,6 +299,7 @@ def preview_subnet(id: int, length: int = Query(ge=1, le=128), db: Session = Dep
def allocate_subnet(id: int, body: s.SubnetNextIn, db: Session = Depends(get_db), user: User = Depends(admin_user)):
"""Создаёт вложенный префикс заданного размера в первом свободном выровненном блоке родителя."""
parent = _lock_prefix(db, id) # до любых чтений дерева (изменение 029/030) — сериализует параллельные выделения из одного родителя
require_org(user, parent.organization_id, "Префикс") # изменение 032
found, _, _ = _find_subnet(db, parent, body.length)
if found is None:
raise HTTPException(409, f"В префиксе {parent.prefix} нет свободного блока /{body.length}")
@@ -297,7 +309,7 @@ def allocate_subnet(id: int, body: s.SubnetNextIn, db: Session = Depends(get_db)
flush(db, "Такой префикс уже есть в этом VRF, повторите запрос")
attach_to_tree(db, p, keep_parent=True)
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 {})})
audit(db, user, "prefix", p, "created", found, {"vrf": parent.vrf.name, "allocated_from": str(parent.prefix), **({"moved_addresses": moved} if moved else {})}, organization_id=parent.organization_id) # изменение 032: organization_id
commit(db, "Такой префикс уже есть в этом VRF, повторите запрос")
return _prefix_outs(db, [p])[0]
@@ -310,10 +322,11 @@ def update_prefix(id: int, body: s.PrefixUpdate, db: Session = Depends(get_db),
# раньше чем прочитан текущий p.vrf_id (изменение 030, ревью 025-029 находка №1); если new_vrf совпадёт
# с текущим VRF, лишняя блокировка того же VRF безвредна
p = _lock_prefix(db, id, new_vrf) if new_vrf is not None else get_or_404(db, Prefix, id, "Префикс")
require_org(user, p.organization_id, "Префикс") # изменение 032
changed = {k: str(v) for k, v in apply_update(p, data).items()}
if new_vrf is not None and new_vrf != p.vrf_id:
changed.update(_move_to_vrf(db, p, new_vrf)[0])
audit(db, user, "prefix", p, "updated", str(p.prefix), changed)
changed.update(_move_to_vrf(db, p, new_vrf, user)[0])
audit(db, user, "prefix", p, "updated", str(p.prefix), changed, organization_id=p.organization_id) # изменение 032: organization_id
commit(db)
return _prefix_outs(db, [p])[0]
@@ -321,12 +334,13 @@ def update_prefix(id: int, body: s.PrefixUpdate, db: Session = Depends(get_db),
@router.delete("/prefixes/{id}", status_code=204)
def delete_prefix(id: int, force: bool = False, db: Session = Depends(get_db), user: User = Depends(admin_user)):
p = _lock_prefix(db, id) # до переподвешивания детей: устаревший p.parent_id иначе достанется всем детям (изменение 029/030)
require_org(user, p.organization_id, "Префикс") # изменение 032
used = None if force else blockers(db, select(func.host(Address.address)).where(Address.prefix_id == id).order_by(Address.address))
if used:
refuse_delete(db, user, "prefix", p, str(p.prefix), "В префиксе есть адреса; удалите их или используйте force=true", {"addresses": used})
refuse_delete(db, user, "prefix", p, str(p.prefix), "В префиксе есть адреса; удалите их или используйте force=true", {"addresses": used}, organization_id=p.organization_id) # изменение 033, находка №4
db.execute(update(Prefix).where(Prefix.parent_id == id).values(parent_id=p.parent_id))
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)
audit(db, user, "prefix", p, "deleted", str(p.prefix), {"force": True, "addresses_deleted": gone} if gone else None, organization_id=p.organization_id) # изменение 032: organization_id
db.delete(p)
commit(db)
@@ -340,9 +354,10 @@ def _addr_out(a: Address, device_name: str | None = None) -> s.AddressOut:
)
def _check_device(db: Session, prefix: Prefix, device_id: int | None):
def _check_device(db: Session, user: User, prefix: Prefix, device_id: int | None):
if device_id is not None:
d = get_or_404(db, Device, device_id, "Устройство")
require_org(user, d.organization_id, "Устройство") # изменение 033, находка №11
if d.organization_id != prefix.organization_id:
raise HTTPException(422, "Устройство принадлежит другой организации")
@@ -350,11 +365,12 @@ 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, ge=1, le=500), offset: int = Query(0, ge=0, le=MAX_OFFSET),
db: Session = Depends(get_db),
db: Session = Depends(get_db), user: User = Depends(current_user), # изменение 032
):
"""status: assigned | reserved | deprecated | free | пусто (все; свободные подмешиваются для малых подсетей).
Пагинация — в SQL; страница «свободных» считается арифметически (без перебора адресов подсети)."""
p = get_or_404(db, Prefix, id, "Префикс")
require_org(user, p.organization_id, "Префикс") # изменение 032
cap = capacity(str(p.prefix))
counts = dict(db.execute(select(Address.status, func.count()).where(Address.prefix_id == id).group_by(Address.status)).all())
stored = sum(counts.values())
@@ -394,6 +410,7 @@ def list_addresses(
@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 = _lock_prefix(db, id) # выбор самого узкого префикса должен видеть согласованное дерево VRF (изменение 029/030)
require_org(user, p.organization_id, "Префикс") # изменение 032
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}")
@@ -403,11 +420,11 @@ def create_address(id: int, body: s.AddressIn, db: Session = Depends(get_db), us
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)
_check_device(db, user, p, body.device_id)
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)
audit(db, user, "address", a, "assigned" if a.status == AddressStatus.assigned else "created", body.address, organization_id=p.organization_id) # изменение 032: organization_id
commit(db, "Адрес уже есть в этом префиксе")
db.refresh(a)
return _addr_out(a)
@@ -419,17 +436,18 @@ def allocate_next(
):
"""Автоназначение первого свободного адреса; только для префиксов с флагом is_pool."""
p = _lock_prefix(db, id) # занятые диапазоны (вложенные префиксы) должны читаться по согласованному дереву (изменение 029/030)
require_org(user, p.organization_id, "Префикс") # изменение 032
if not p.is_pool:
raise HTTPException(422, "Префикс не является пулом для автоназначения")
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"))
_check_device(db, user, p, data.get("device_id"))
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)
audit(db, user, "address", a, "assigned", ip, organization_id=p.organization_id) # изменение 032: organization_id
commit(db, "Адрес уже занят, повторите запрос")
db.refresh(a)
return _addr_out(a)
@@ -438,12 +456,14 @@ def allocate_next(
@router.patch("/addresses/{id}", response_model=s.AddressOut)
def update_address(id: int, body: s.AddressUpdate, db: Session = Depends(get_db), user: User = Depends(admin_user)):
a = get_or_404(db, Address, id, "Адрес")
p = db.get(Prefix, a.prefix_id)
require_org(user, p.organization_id, "Адрес") # изменение 032
data = body.model_dump(exclude_unset=True)
if data.get("dns_name"):
s.AddressIn(address=s.ip_text(a.address), dns_name=data["dns_name"]) # валидация FQDN
_check_device(db, db.get(Prefix, a.prefix_id), data.get("device_id"))
_check_device(db, user, p, data.get("device_id"))
changed = apply_update(a, data)
audit(db, user, "address", a, "updated", s.ip_text(a.address), {k: str(v) for k, v in changed.items()})
audit(db, user, "address", a, "updated", s.ip_text(a.address), {k: str(v) for k, v in changed.items()}, organization_id=p.organization_id) # изменение 032: organization_id
commit(db)
db.refresh(a)
return _addr_out(a)
@@ -452,6 +472,8 @@ def update_address(id: int, body: s.AddressUpdate, db: Session = Depends(get_db)
@router.delete("/addresses/{id}", status_code=204)
def delete_address(id: int, db: Session = Depends(get_db), user: User = Depends(admin_user)):
a = get_or_404(db, Address, id, "Адрес")
audit(db, user, "address", a, "deleted", s.ip_text(a.address))
p = db.get(Prefix, a.prefix_id)
require_org(user, p.organization_id, "Адрес") # изменение 032
audit(db, user, "address", a, "deleted", s.ip_text(a.address), organization_id=p.organization_id) # изменение 032: organization_id
db.delete(a)
commit(db)
+73 -44
View File
@@ -8,8 +8,8 @@ from app.db import get_db
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 MAX_OFFSET, apply_update, audit, blockers, commit, contains, count, flush, get_or_404, refuse_delete
from app.security import admin_user, current_user, superadmin_user
from app.services import MAX_OFFSET, apply_update, audit, blockers, commit, contains, count, flush, get_or_404, refuse_delete, require_org, scope_org
from fastapi import HTTPException
router = APIRouter(dependencies=[Depends(current_user)])
@@ -37,8 +37,9 @@ def _org_out(db: Session, o: Organization) -> s.OrgOut:
@router.get("/organizations", response_model=s.Page[s.OrgOut], tags=["organizations"])
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)):
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), user: User = Depends(current_user)):
stmt = select(Organization)
stmt = scope_org(stmt, Organization.id, user) # изменение 032
if q:
stmt = stmt.where(or_(*(contains(c, q) for c in (Organization.name, Organization.short_name, Organization.inn, Organization.address))))
total = count(db, stmt)
@@ -47,43 +48,46 @@ def list_orgs(q: str = "", limit: int = Query(100, ge=1, le=500), offset: int =
@router.get("/organizations/{id}", response_model=s.OrgOut, tags=["organizations"])
def get_org(id: int, db: Session = Depends(get_db)):
def get_org(id: int, db: Session = Depends(get_db), user: User = Depends(current_user)): # изменение 032
require_org(user, id, "Организация")
return _org_out(db, get_or_404(db, Organization, id, "Организация"))
@router.post("/organizations", response_model=s.OrgOut, status_code=201, tags=["organizations"])
def create_org(body: s.OrgIn, db: Session = Depends(get_db), user: User = Depends(admin_user)):
def create_org(body: s.OrgIn, db: Session = Depends(get_db), user: User = Depends(superadmin_user)): # изменение 032: только superadmin
o = Organization(**body.model_dump())
db.add(o)
flush(db, "Организация с таким названием или ИНН уже существует")
db.add(Vrf(organization_id=o.id, name="default"))
audit(db, user, "organization", o, "created", o.name)
audit(db, user, "organization", o, "created", o.name, organization_id=o.id) # изменение 032: organization_id
commit(db, "Организация с таким названием или ИНН уже существует")
return _org_out(db, o)
@router.patch("/organizations/{id}", response_model=s.OrgOut, tags=["organizations"])
def update_org(id: int, body: s.OrgIn, db: Session = Depends(get_db), user: User = Depends(admin_user)):
def update_org(id: int, body: s.OrgIn, db: Session = Depends(get_db), user: User = Depends(admin_user)): # изменение 032: admin_user
require_org(user, id, "Организация") # изменение 032
o = get_or_404(db, Organization, id, "Организация")
changed = apply_update(o, body.model_dump())
audit(db, user, "organization", o, "updated", o.name, changed)
audit(db, user, "organization", o, "updated", o.name, changed, organization_id=id) # изменение 032: organization_id
commit(db, "Организация с таким названием или ИНН уже существует")
return _org_out(db, o)
@router.delete("/organizations/{id}", status_code=204, tags=["organizations"])
def delete_org(id: int, db: Session = Depends(get_db), user: User = Depends(admin_user)):
def delete_org(id: int, db: Session = Depends(get_db), user: User = Depends(superadmin_user)): # изменение 032: только superadmin
o = get_or_404(db, Organization, id, "Организация")
found = {
"prefixes": blockers(db, select(func.concat(cast(Prefix.prefix, String), " (", Vrf.name, ")")).select_from(Prefix).join(Vrf, Vrf.id == Prefix.vrf_id)
.where(Prefix.organization_id == id).order_by(Prefix.id)),
"devices": blockers(db, select(Device.name).where(Device.organization_id == id).order_by(Device.id)),
"isps": blockers(db, select(Isp.name).where(Isp.organization_id == id).order_by(Isp.id)),
"users": blockers(db, select(User.username).where(User.organization_id == id).order_by(User.id)), # изменение 032: пользователи
}
if any(found.values()):
refuse_delete(db, user, "organization", o, o.name, "Нельзя удалить: у организации есть префиксы, устройства или операторы", found)
refuse_delete(db, user, "organization", o, o.name, "Нельзя удалить: у организации есть привязанные объекты", found, organization_id=id) # изменение 033, находка №4
db.execute(delete(Vrf).where(Vrf.organization_id == id)) # немедленно: между Vrf и Organization нет relationship(), порядок DELETE в UoW не гарантирован
audit(db, user, "organization", o, "deleted", o.name)
audit(db, user, "organization", o, "deleted", o.name, organization_id=id) # изменение 032: organization_id
db.delete(o)
commit(db)
@@ -105,49 +109,59 @@ def _vrf_out(db: Session, v: Vrf) -> s.VrfOut:
@router.get("/vrfs", response_model=s.Page[s.VrfOut], tags=["vrf"])
def list_vrfs(organization_id: int | None = None, db: Session = Depends(get_db)):
def list_vrfs(organization_id: int | None = None, db: Session = Depends(get_db), user: User = Depends(current_user)): # изменение 032
stmt = select(Vrf)
if organization_id:
require_org(user, organization_id, "Организация") # изменение 032
stmt = stmt.where(Vrf.organization_id == organization_id)
else:
stmt = scope_org(stmt, Vrf.organization_id, user) # изменение 032
rows = db.scalars(stmt.order_by(Vrf.id)).all()
return s.Page(items=_vrf_outs(db, list(rows)), total=len(rows))
@router.post("/vrfs", response_model=s.VrfOut, status_code=201, tags=["vrf"])
def create_vrf(body: s.VrfIn, db: Session = Depends(get_db), user: User = Depends(admin_user)):
def create_vrf(body: s.VrfIn, db: Session = Depends(get_db), user: User = Depends(admin_user)): # изменение 032: admin_user вместо admin
require_org(user, body.organization_id, "Организация") # изменение 032
get_or_404(db, Organization, body.organization_id, "Организация")
v = Vrf(**body.model_dump())
db.add(v)
flush(db, "VRF с таким названием уже есть в организации")
audit(db, user, "vrf", v, "created", v.name)
audit(db, user, "vrf", v, "created", v.name, organization_id=body.organization_id) # изменение 032: organization_id
commit(db, "VRF с таким названием уже есть в организации")
return _vrf_out(db, v)
@router.patch("/vrfs/{id}", response_model=s.VrfOut, tags=["vrf"])
def update_vrf(id: int, body: s.VrfUpdate, db: Session = Depends(get_db), user: User = Depends(admin_user)):
def update_vrf(id: int, body: s.VrfUpdate, db: Session = Depends(get_db), user: User = Depends(admin_user)): # изменение 032
v = get_or_404(db, Vrf, id, "VRF")
require_org(user, v.organization_id, "VRF") # изменение 032
changed = apply_update(v, body.model_dump(exclude_unset=True, exclude_none=True))
audit(db, user, "vrf", v, "updated", v.name, changed)
audit(db, user, "vrf", v, "updated", v.name, changed, organization_id=v.organization_id) # изменение 032: organization_id
commit(db, "VRF с таким названием уже есть в организации")
return _vrf_out(db, v)
@router.delete("/vrfs/{id}", status_code=204, tags=["vrf"])
def delete_vrf(id: int, db: Session = Depends(get_db), user: User = Depends(admin_user)):
def delete_vrf(id: int, db: Session = Depends(get_db), user: User = Depends(admin_user)): # изменение 032
v = get_or_404(db, Vrf, id, "VRF")
require_org(user, v.organization_id, "VRF") # изменение 032
used = blockers(db, select(cast(Prefix.prefix, String)).where(Prefix.vrf_id == id).order_by(Prefix.id))
if used:
refuse_delete(db, user, "vrf", v, v.name, "Нельзя удалить: VRF используется префиксами", {"prefixes": used})
audit(db, user, "vrf", v, "deleted", v.name)
refuse_delete(db, user, "vrf", v, v.name, "Нельзя удалить: VRF используется префиксами", {"prefixes": used}, organization_id=v.organization_id) # изменение 033, находка №4
audit(db, user, "vrf", v, "deleted", v.name, organization_id=v.organization_id) # изменение 032: organization_id
db.delete(v)
commit(db)
# ---------------------------------------------------------------- device types
def _type_outs(db: Session, rows: list[DeviceType]) -> list[s.DeviceTypeOut]:
def _type_outs(db: Session, rows: list[DeviceType], user: User | None = None) -> list[s.DeviceTypeOut]:
"""изменение 033, находка №12: счётчик — в границах организации пользователя, а не по всем организациям."""
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 {}
stmt = select(Device.device_type_id, func.count()).where(Device.device_type_id.in_(ids))
if user is not None:
stmt = scope_org(stmt, Device.organization_id, user)
counts = dict(db.execute(stmt.group_by(Device.device_type_id)).all()) if ids else {}
out = []
for t in rows:
item = s.DeviceTypeOut.model_validate(t)
@@ -156,18 +170,18 @@ def _type_outs(db: Session, rows: list[DeviceType]) -> list[s.DeviceTypeOut]:
return out
def _type_out(db: Session, t: DeviceType) -> s.DeviceTypeOut:
return _type_outs(db, [t])[0]
def _type_out(db: Session, t: DeviceType, user: User | None = None) -> s.DeviceTypeOut:
return _type_outs(db, [t], user)[0]
@router.get("/device-types", response_model=s.Page[s.DeviceTypeOut], tags=["devices"])
def list_types(db: Session = Depends(get_db)):
def list_types(db: Session = Depends(get_db), user: User = Depends(current_user)): # изменение 033, находка №12
rows = db.scalars(select(DeviceType).order_by(DeviceType.id)).all()
return s.Page(items=_type_outs(db, list(rows)), total=len(rows))
return s.Page(items=_type_outs(db, list(rows), user), total=len(rows))
@router.post("/device-types", response_model=s.DeviceTypeOut, status_code=201, tags=["devices"])
def create_type(body: s.DeviceTypeIn, db: Session = Depends(get_db), user: User = Depends(admin_user)):
def create_type(body: s.DeviceTypeIn, db: Session = Depends(get_db), user: User = Depends(superadmin_user)): # изменение 032: только superadmin
t = DeviceType(name=body.name)
db.add(t)
flush(db, "Тип с таким названием уже существует")
@@ -177,7 +191,7 @@ def create_type(body: s.DeviceTypeIn, db: Session = Depends(get_db), user: User
@router.patch("/device-types/{id}", response_model=s.DeviceTypeOut, tags=["devices"])
def update_type(id: int, body: s.DeviceTypeIn, db: Session = Depends(get_db), user: User = Depends(admin_user)):
def update_type(id: int, body: s.DeviceTypeIn, db: Session = Depends(get_db), user: User = Depends(superadmin_user)): # изменение 032: только superadmin
t = get_or_404(db, DeviceType, id, "Тип")
changed = apply_update(t, {"name": body.name})
audit(db, user, "device_type", t, "updated", t.name, changed)
@@ -186,7 +200,7 @@ def update_type(id: int, body: s.DeviceTypeIn, db: Session = Depends(get_db), us
@router.delete("/device-types/{id}", status_code=204, tags=["devices"])
def delete_type(id: int, db: Session = Depends(get_db), user: User = Depends(admin_user)):
def delete_type(id: int, db: Session = Depends(get_db), user: User = Depends(superadmin_user)): # изменение 032: только superadmin
t = get_or_404(db, DeviceType, id, "Тип")
if t.is_default:
refuse_delete(db, user, "device_type", t, t.name, "Нельзя удалить тип по умолчанию")
@@ -228,11 +242,14 @@ def _device_out(db: Session, d: Device) -> s.DeviceOut:
@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, ge=1, le=500), offset: int = Query(0, ge=0, le=MAX_OFFSET), 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), user: User = Depends(current_user), # изменение 032
):
stmt = select(Device)
if organization_id:
require_org(user, organization_id, "Организация") # изменение 032
stmt = stmt.where(Device.organization_id == organization_id)
else:
stmt = scope_org(stmt, Device.organization_id, user) # изменение 032
if device_type_id:
stmt = stmt.where(Device.device_type_id == device_type_id)
if q:
@@ -244,38 +261,43 @@ def list_devices(
@router.post("/devices", response_model=s.DeviceOut, status_code=201, tags=["devices"])
def create_device(body: s.DeviceIn, db: Session = Depends(get_db), user: User = Depends(admin_user)):
def create_device(body: s.DeviceIn, db: Session = Depends(get_db), user: User = Depends(admin_user)): # изменение 032: admin_user вместо admin
require_org(user, body.organization_id, "Организация") # изменение 032
get_or_404(db, Organization, body.organization_id, "Организация")
get_or_404(db, DeviceType, body.device_type_id, "Тип")
d = Device(**body.model_dump())
db.add(d)
flush(db, "Устройство с таким именем уже есть в организации")
audit(db, user, "device", d, "created", d.name)
audit(db, user, "device", d, "created", d.name, organization_id=body.organization_id) # изменение 032: organization_id
commit(db, "Устройство с таким именем уже есть в организации")
return _device_out(db, d)
@router.get("/devices/{id}", response_model=s.DeviceOut, tags=["devices"])
def get_device(id: int, db: Session = Depends(get_db)):
return _device_out(db, get_or_404(db, Device, id, "Устройство"))
def get_device(id: int, db: Session = Depends(get_db), user: User = Depends(current_user)): # изменение 032
d = get_or_404(db, Device, id, "Устройство")
require_org(user, d.organization_id, "Устройство") # изменение 032
return _device_out(db, d)
@router.patch("/devices/{id}", response_model=s.DeviceOut, tags=["devices"])
def update_device(id: int, body: s.DeviceUpdate, db: Session = Depends(get_db), user: User = Depends(admin_user)):
def update_device(id: int, body: s.DeviceUpdate, db: Session = Depends(get_db), user: User = Depends(admin_user)): # изменение 032
d = get_or_404(db, Device, id, "Устройство")
require_org(user, d.organization_id, "Устройство") # изменение 032
data = body.model_dump(exclude_unset=True, exclude_none=True)
if "device_type_id" in data:
get_or_404(db, DeviceType, data["device_type_id"], "Тип")
changed = apply_update(d, data)
audit(db, user, "device", d, "updated", d.name, changed)
audit(db, user, "device", d, "updated", d.name, changed, organization_id=d.organization_id) # изменение 032: organization_id
commit(db, "Устройство с таким именем уже есть в организации")
return _device_out(db, d)
@router.delete("/devices/{id}", status_code=204, tags=["devices"])
def delete_device(id: int, db: Session = Depends(get_db), user: User = Depends(admin_user)):
def delete_device(id: int, db: Session = Depends(get_db), user: User = Depends(admin_user)): # изменение 032
d = get_or_404(db, Device, id, "Устройство")
audit(db, user, "device", d, "deleted", d.name)
require_org(user, d.organization_id, "Устройство") # изменение 032
audit(db, user, "device", d, "deleted", d.name, organization_id=d.organization_id) # изменение 032: organization_id
db.delete(d)
commit(db)
@@ -298,11 +320,14 @@ def _isp_out(db: Session, i: Isp) -> s.IspOut:
@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, ge=1, le=500), offset: int = Query(0, ge=0, le=MAX_OFFSET),
db: Session = Depends(get_db),
db: Session = Depends(get_db), user: User = Depends(current_user), # изменение 032
):
stmt = select(Isp)
if organization_id:
require_org(user, organization_id, "Организация") # изменение 032
stmt = stmt.where(Isp.organization_id == organization_id)
else:
stmt = scope_org(stmt, Isp.organization_id, user) # изменение 032
if q:
nets = select(IspNetwork.isp_id).where(contains(cast(IspNetwork.cidr, String), q))
orgs = select(Organization.id).where(contains(Organization.name, q))
@@ -313,34 +338,38 @@ def list_isps(
@router.post("/isps", response_model=s.IspOut, status_code=201, tags=["isps"])
def create_isp(body: s.IspIn, db: Session = Depends(get_db), user: User = Depends(admin_user)):
def create_isp(body: s.IspIn, db: Session = Depends(get_db), user: User = Depends(admin_user)): # изменение 032: admin_user вместо admin
require_org(user, body.organization_id, "Организация") # изменение 032
get_or_404(db, Organization, body.organization_id, "Организация")
data = body.model_dump()
nets = data.pop("networks")
i = Isp(**data, networks=[IspNetwork(cidr=n) for n in nets])
db.add(i)
db.flush()
audit(db, user, "isp", i, "created", i.name)
audit(db, user, "isp", i, "created", i.name, organization_id=body.organization_id) # изменение 032: organization_id
commit(db)
return _isp_out(db, i)
@router.put("/isps/{id}", response_model=s.IspOut, tags=["isps"])
def update_isp(id: int, body: s.IspIn, db: Session = Depends(get_db), user: User = Depends(admin_user)):
def update_isp(id: int, body: s.IspIn, db: Session = Depends(get_db), user: User = Depends(admin_user)): # изменение 032
i = get_or_404(db, Isp, id, "Оператор")
require_org(user, i.organization_id, "Оператор") # изменение 032
require_org(user, body.organization_id, "Организация") # изменение 033, находка №11: до get_or_404 чужой организации
get_or_404(db, Organization, body.organization_id, "Организация")
data = body.model_dump()
nets = data.pop("networks")
changed = apply_update(i, data)
i.networks = [IspNetwork(cidr=n) for n in nets]
audit(db, user, "isp", i, "updated", i.name, changed)
audit(db, user, "isp", i, "updated", i.name, changed, organization_id=i.organization_id) # изменение 032: organization_id
commit(db)
return _isp_out(db, i)
@router.delete("/isps/{id}", status_code=204, tags=["isps"])
def delete_isp(id: int, db: Session = Depends(get_db), user: User = Depends(admin_user)):
def delete_isp(id: int, db: Session = Depends(get_db), user: User = Depends(admin_user)): # изменение 032
i = get_or_404(db, Isp, id, "Оператор")
audit(db, user, "isp", i, "deleted", i.name)
require_org(user, i.organization_id, "Оператор") # изменение 032
audit(db, user, "isp", i, "deleted", i.name, organization_id=i.organization_id) # изменение 032: organization_id
db.delete(i)
commit(db)
+47 -32
View File
@@ -1,8 +1,9 @@
"""Пользователи: учётные записи UI (роли admin/viewer), смена своего пароля.
"""Пользователи: учётные записи UI (роли superadmin/admin/viewer), смена своего пароля.
Правила: логин после создания не меняется (он же `sub` в JWT), нельзя отключить/понизить/удалить
свою учётную запись и последнего активного администратора; зарезервированные логины `system`
свою учётную запись и последнего активного суперадминистратора; зарезервированные логины `system`
и `anonymous` запрещены — журнал различает по ним служебные события (`actor_of` в app/services.py).
Управление пользователями — только superadmin (изменение 032).
"""
from datetime import datetime, timezone
@@ -12,29 +13,29 @@ 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.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_ADMIN_LOCK = 703002 # advisory lock: изменения прав администраторов идут по одному (иначе двое отключат друг друга одновременно)
USERS_LOCK = 703002 # advisory lock: изменения прав суперадминистраторов идут по одному (иначе двое отключат друг друга одновременно)
def _lock_admins(db: Session) -> None:
db.execute(select(func.pg_advisory_xact_lock(USERS_ADMIN_LOCK)))
def _lock_users(db: Session) -> None:
db.execute(select(func.pg_advisory_xact_lock(USERS_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))
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), admin: User = Depends(admin_user),
db: Session = Depends(get_db), user: User = Depends(superadmin_user), # изменение 032
):
stmt = select(User)
if q.strip():
@@ -45,54 +46,68 @@ def list_users(
@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)):
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, "Пользователь с таким логином уже существует")
u = User(username=body.username, password_hash=hash_password(body.password), role=body.role, is_active=body.is_active)
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, "Пользователь с таким логином уже существует")
audit(db, admin, "user", u, "created", u.username)
commit(db, "Пользователь с таким логином уже существует")
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), admin: User = Depends(admin_user)):
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_admins(db) # до чтения пользователя и подсчёта администраторов
_lock_users(db) # до чтения пользователя и подсчёта суперадминистраторов (изменение 032)
u = get_or_404(db, User, id, "Пользователь")
if u.id == admin.id and "password" in data:
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_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):
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_admin and _other_active_admins(db, u.id) == 0:
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, admin, "user", u, "updated", u.username, changed)
audit(db, user, "user", u, "updated", u.username, changed)
if pwd:
audit(db, admin, "user", u, "password_reset", u.username, message=f"Пользователь {u.username}: пароль изменён администратором")
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), admin: User = Depends(admin_user)):
_lock_admins(db)
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 == 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)
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)