Files
ipam_control/app/api/v1/refs.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

376 lines
21 KiB
Python

"""Справочники: организации, VRF, операторы, типы устройств, устройства."""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import String, cast, delete, func, or_, select
from sqlalchemy.orm import Session, selectinload
from app import schemas as s
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, 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)])
# ---------------------------------------------------------------- organizations
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, 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)
rows = db.scalars(stmt.order_by(Organization.id).limit(limit).offset(offset)).all()
return s.Page(items=_org_outs(db, list(rows)), total=total)
@router.get("/organizations/{id}", response_model=s.OrgOut, tags=["organizations"])
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(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, 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)): # изменение 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, 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(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, 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, organization_id=id) # изменение 032: organization_id
db.delete(o)
commit(db)
# ------------------------------------------------------------------------- VRF
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), 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)): # изменение 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, 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)): # изменение 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, 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)): # изменение 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}, 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], user: User | None = None) -> list[s.DeviceTypeOut]:
"""изменение 033, находка №12: счётчик — в границах организации пользователя, а не по всем организациям."""
ids = [t.id for t in rows]
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)
item.devices_count = counts.get(t.id, 0)
out.append(item)
return out
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), 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), 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(superadmin_user)): # изменение 032: только superadmin
t = DeviceType(name=body.name)
db.add(t)
flush(db, "Тип с таким названием уже существует")
audit(db, user, "device_type", t, "created", t.name)
commit(db, "Тип с таким названием уже существует")
return _type_out(db, t)
@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(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)
commit(db, "Тип с таким названием уже существует")
return _type_out(db, t)
@router.delete("/device-types/{id}", status_code=204, tags=["devices"])
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, "Нельзя удалить тип по умолчанию")
used = blockers(db, select(Device.name).where(Device.device_type_id == id).order_by(Device.id))
if used:
refuse_delete(db, user, "device_type", t, t.name, "Нельзя удалить: тип используется устройствами", {"devices": used})
audit(db, user, "device_type", t, "deleted", t.name)
db.delete(t)
commit(db)
# --------------------------------------------------------------------- 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:
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, 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:
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_outs(db, list(rows)), total=total)
@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)): # изменение 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, 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), 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)): # изменение 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, 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)): # изменение 032
d = get_or_404(db, Device, id, "Устройство")
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)
# ------------------------------------------------------------------------ ISPs
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, 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(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))
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.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"])
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, 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)): # изменение 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, 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)): # изменение 032
i = get_or_404(db, Isp, id, "Оператор")
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)