Задачи 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:
1 parent
5210ba3333
commit
744a025960
28 files changed
+1283
-164
No files matched your search
+19
-11
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user